Skip to content

Comparison

This page puts Nano Kit next to the libraries it is usually weighed against, one per approach: atoms with lifecycles (Nano Stores), a single store with selectors (Zustand) and atoms (Jotai) for the store layer, and the same-family option plus the standard for data fetching and routing (@nanostores/*, TanStack). Every number is measured, every feature claim comes from the other library’s documentation.

Nano KitNano StoresZustandJotai
ModelSignals with a push-pull dependency graphAtoms with listenersOne store, slices through selectorsAtoms with a dependency graph
Framework-agnostic coreYesYesYes, zustand/vanillaYes, jotai/vanilla; the API is React-first
FrameworksReact, Preact, Svelte, SvelteKit, Next.jsReact, Preact, Vue, Svelte, Solid, Lit, Angular, AlpineReact, vanilla store elsewhereReact, vanilla store elsewhere
Derived statecomputed: lazy and cachedcomputed, batchedSelectors, re-run on every store changeDerived atoms
Effectseffecteffectsubscribe, with a selector through subscribeWithSelectorstore.sub
Store lifecycleonMount with a delayed unmountonMount with a delayed unmountNoatom.onMount
Dependency injectionBuilt in: inject, provide, InjectionContextNoManual, a store passed through React contextProvider scoping
Per-request isolation for SSROne InjectionContext per requestManualManual, one store per requestOne Provider per request
Hydration helpershydratable, dehydration and hydration in store, query, router and the SSR adaptersManualManualuseHydrateAtoms
Data fetching@nano_kit/query, same reactive core@nanostores/queryBring your ownjotai-tanstack-query
Router@nano_kit/router, same reactive core@nanostores/routerNoNo
i18n@nano_kit/intl, same reactive core@nanostores/i18nNoNo
DevTools@nano_kit/devtools: a panel on the page with every signal, its value and links, and a log of transactionsLogger, Vue Devtools pluginRedux DevTools middlewarejotai-devtools
Import setMinifiedMinified + gzip
@nano_kit/store: signal, computed, effect, mountable, onMount5.71 kB2.14 kB
nanostores: atom, computed, effect, onMount1.99 kB0.96 kB
zustand/vanilla: createStore plus subscribeWithSelector0.55 kB0.34 kB
jotai/vanilla: atom, createStore5.74 kB2.41 kB

Nano Kit started from the ideas of Nano Stores and keeps them: atomic stores, onMount with its delayed unmount, logic moved out of components, a tree-shakeable API. If Nano Stores works for you, there is no reason to switch. Nano Kit exists because a real application on Nano Stores kept needing things the atom does not provide, and we kept writing them next to it:

  • DX. An application needs more than an atom, and Nano Stores leaves the rest to you. Nano Kit ships it: records with array and object operations, resolved for async state, rate limiting, codecs for anything stored as a string, localStored, cookieStored and the other browser bindings, typed route params and cache keys. The API itself is built for daily use too: signals are callables, $count() reads and $count(1) writes.
  • SSR. Nano Stores keeps stores at module level and documents SSR as “use standard strategies”. On a real server that does not hold: every request handled at the same time shares the same module-level atoms, so state has to be isolated per request, and Nano Stores neither does it nor offers a recipe for it in the docs. Nano Kit builds the store graph inside an InjectionContext, so two concurrent requests never share a signal, and dehydration and hydration are part of the store, query and router packages.
  • Designed together. @nanostores/router and @nanostores/query are built on Nano Stores atoms too; what Nano Kit adds is the layer above the atom: a URL param signal is a query parameter with no glue code in between, one InjectionContext isolates the router, the query cache and the stores per request, and hydration covers all three in one flow.
  • Throughput. The reactive core is Agera, a fork of alien-signals. Values are pulled lazily through a dependency graph instead of pushed through listener chains. In the effect benchmark that is 3.3M subscription updates per second against 0.96M for Nano Stores, within 6% of alien-signals itself.

Nano Stores stays lighter by about 1.2 kB, and that is fair: an atom with a listener list is less code than a dependency graph. Those bytes are the dependency graph that makes computeds lazy and updates granular, and nothing else: DI, SSR helpers, query, router and intl are separate imports that cost nothing until used.

Zustand keeps one store object and lets components pick slices with selectors. It is small, needs no providers, and for a React-only app with one store it is hard to beat. The differences show up when logic has to live outside components.

Nano Kit has many small signals instead of one object, so an update wakes only the effects that read the changed value. computed caches its result and recalculates only when an input changes. A store can own resources: onMount starts a timer, a socket or a request with the first subscriber and stops it after the last one leaves.

/* Zustand: the component owns the resource */
const useTemperature = create<{ value: number }>(() => ({ value: 20 }))
function Thermometer() {
const value = useTemperature(state => state.value)
useEffect(() => {
/* readSensor() is whatever produces the next value */
const id = setInterval(() => useTemperature.setState({ value: readSensor() }), 1000)
return () => clearInterval(id)
}, [])
return <span>{value}</span>
}
/* Nano Kit: the store owns the resource */
const $temperature = mountable(signal(20))
onMount($temperature, () => {
const id = setInterval(() => $temperature(readSensor()), 1000)
return () => clearInterval(id)
})
function Thermometer() {
const value = useSignal($temperature)
return <span>{value}</span>
}

The second version works the same in Preact and Svelte, in a Node test without any UI framework, and on the server inside a per-request injection context. This is the point of Nano Kit’s architecture: business logic lives in the store layer and components do one thing, render. The store owns its data and its resources, the view subscribes and draws, and neither needs to know how the other works. That is the trade: about 2 kB more than Zustand’s vanilla store, spent on the dependency graph and the lifecycle, and the graph is what delivers 3.3M subscription updates per second against 1.8M for subscribeWithSelector in the effect benchmark.

Jotai is the closest model in the table: atoms, derived atoms, atom.onMount for lifecycles, Provider for scoping. The difference is where values live. A Jotai atom is a key, and the value sits in a store you read through store.get, useAtom or a get callback. A Nano Kit signal carries its own value and is a callable: $count() reads, $count(1) writes, and an effect tracks whatever it reads.

/* Jotai: atoms are keys, the store holds the values */
const countAtom = atom(0)
const doubleAtom = atom(get => get(countAtom) * 2)
const store = createStore()
store.sub(doubleAtom, () => console.log(store.get(doubleAtom)))
store.set(countAtom, 1)
/* Nano Kit: signals hold their values, effects track reads */
const $count = signal(0)
const $double = computed(() => $count() * 2)
effect(() => console.log($double()))
$count(1)

The numbers follow: the effect benchmark puts Jotai at about 0.12M updates per second against 3.3M for @nano_kit/store, and its vanilla core is also the larger of the two at 2.41 kB against 2.14 kB. Jotai gives you async atoms with Suspense and a large utils collection. Nano Kit gives you the same atomic model with a faster and smaller core, onMount with a delayed unmount, dependency injection instead of a Provider, adapters for Preact and Svelte next to React, query, router and intl built on the same signals, and a DevTools panel that shows all of them in one graph.

@nano_kit/query@nanostores/queryTanStack Query
Framework-agnostic coreYesYesYes, @tanstack/query-core
Cache keysqueryKey with typed params and data: queryKey<[id: number], Post>Strings, or arrays of strings and storesArrays; the data type is attached through queryOptions and data tags
Reactive parametersSignals: a change refetchesStores as key partsA new key on re-render
Dedupe and stale-while-revalidateYes, dedupeTime and cacheTimeYesYes
Mutationsmutations() extensioncreateMutatorStoreMutationObserver, useMutation
Optimistic updates$data(key, value) returns a revert functiongetCacheUpdateronMutate with a manual rollback
Normalized entitiesentities(): one entity shared by every query that references itNot built inNot built in
Infinite queriesinfinites() extensionPagination recipeInfiniteQueryObserver
Persistencepersistence() with indexedDbStorage()Not built in@tanstack/query-persist-client-core
SSR hydrationhydratable() setting: tracked, awaited, dehydratedManualdehydrate and hydrate
Retry, abort, revalidate on focus or reconnectretryOnError(), abortable(), revalidateOn()Built inBuilt in
DevTools@nano_kit/devtools: the cache as signals, every update in the logNoYes
Import setMinifiedMinified + gzip
@nano_kit/query: client, queryKey, includes the store core10.37 kB3.88 kB
@nano_kit/query: plus mutations, entities, includes the store core11.41 kB4.28 kB
@nanostores/query: nanoquery, includes nanostores6.58 kB2.94 kB
@tanstack/query-core: QueryClient, QueryObserver30.65 kB8.85 kB
@tanstack/query-core: plus MutationObserver32.43 kB9.19 kB

@nanostores/query is the same idea one generation earlier: a fetcher store with stale-while-revalidate, dedupe, revalidation on focus, reconnect and interval, and a mutator store with getCacheUpdater for optimistic changes. Keys are strings or arrays of strings and stores, so the type of the cached data is not attached to the key. Nano Kit keeps the shape and adds what the atom API could not carry: typed keys, entities(), persistence(), server rendering that awaits queries as tasks and dehydrates them with one hydratable() setting, and a router whose URL params are signals a query can take directly. @nanostores/query measures 2.94 kB against 3.88 kB, and the gap is the reactive core underneath, not the features: everything past client and queryKey is tree-shaken until imported, and the query layer itself weighs about the same on both cores.

TanStack Query is the standard for server state, and its model is built around components: a query observer per hook, a key array rebuilt on every render, and a re-render when the key changes. @nano_kit/query builds the same cache on signals, so a parameter is a signal and the query follows it without any wiring in the component.

/* TanStack Query: the key is rebuilt on every render */
const { data } = useQuery({
queryKey: ['post', id],
queryFn: () => fetchPost(id)
})
/* Nano Kit: the parameter is a signal, the query follows it */
const [$post] = query(PostKey, [$postId], fetchPost)

Keys are typed at the source: queryKey<[id: number], Post> fixes both the parameters and the data, so $data(PostKey(1)) reads and writes a Post with no casts, and the write returns a revert function for optimistic updates. entities() keeps one copy of a record shared by every query that mentions it, which TanStack leaves to manual setQueryData calls. TanStack Query gives you the largest ecosystem in the category and DevTools built around queries. Nano Kit gives you a core less than half the size, 3.88 kB against 8.85 kB gzipped with the store included, and at the application level the gap holds: the Rick and Morty app bundled with Vite 8 is 73.34 kB gzipped with @nano_kit/query and @nano_kit/router, and 101.70 kB with TanStack Query and TanStack Router.

@nano_kit/router and @nanostores/router share the idea that a router is a store, not a component. TanStack Router is the type-safe heavyweight of the category. The first @nano_kit/router row matches the @nanostores/router import set, the second imports everything TanStack Router core covers, and the third adds the query client in the role of loaders.

Import setMinifiedMinified + gzip
@nano_kit/router: browserNavigation, param, includes the store core8.29 kB3.25 kB
@nanostores/router: createRouter, includes nanostores3.11 kB1.53 kB
@nano_kit/router, the feature set of TanStack Router core: navigation, params and search params, router with page, layout and notFound, links, loadable, head and scroll management, includes the store core13.22 kB5.15 kB
The same plus client and queryKey from @nano_kit/query as the loader layer17.85 kB6.83 kB
@tanstack/router-core: RouterCore, BaseRootRoute, BaseRoute, plus createBrowserHistory from @tanstack/history55.60 kB19.56 kB

@nanostores/router is one store that holds the current route: typed params, parsed search params, link click tracking, URL generation through getPagePath and openPage, and navigation prevention through onSet, all in 1.53 kB, and that is the whole feature list. @nano_kit/router starts from the same place with browserNavigation and param, then adds everything that one store with the current route cannot cover: search params as individual signals that feed queries directly, a router that composes page, layout and notFound into a page tree, loadable for code splitting, head and scroll management, Stores$ and Head$ page hooks that dehydrate data and document head for SSR, virtualNavigation with its own history stack for tests and the server, and typed route names through AppContext. None of that is in your bundle until you import it: past browserNavigation and param every feature is tree-shaken, which is why the full set costs 5.15 kB only when you use all of it. Most of the 1.7 kB gap in the first row is the reactive core underneath, @nano_kit/store against nanostores, not routing code.

TanStack Router is the most complete router in the ecosystem: type-safe file-based routes, loaders with caching, search param validation with schemas, preloading and DevTools, with adapters for React and Solid. Its framework-agnostic core with history weighs 19.56 kB gzipped. @nano_kit/router covers routing, params, search params, code splitting, head and scroll in 5.15 kB, and with @nano_kit/query in the role of loaders the whole stack is 6.83 kB, about 2.9 times smaller than the TanStack core alone. What you trade is schema validation of search params and file-based route generation. What you get is one signal graph shared by the router, the query cache and the stores, the same code in React, Preact, Svelte and SvelteKit, and SSR through Stores$ and Head$ without a meta-framework. The Rick and Morty app shows the difference end to end: 73.34 kB against 101.70 kB gzipped.

No library wins every row. Here is what Nano Kit gives up today, and what it gives back.

  • The ecosystem is younger and smaller than Zustand’s or TanStack’s. In return, it is designed as one piece: store, query, router and intl share a core and a mental model, so there is no glue code between them and no version matrix to reconcile.
  • The store is not the smallest one you can install. In return, the bytes above Nano Stores buy the dependency graph and the throughput that comes with it, everything else is a separate import that costs nothing until used, and the example apps come out smaller than the same apps on mainstream stacks.
  • Data fetching is client-first, not React Server Components first. In return, the same store code runs in React, Preact and Svelte, in tests, and on the server without a meta-framework; Next.js is supported through hydration.

Start with the Introduction, then build an app from the first store to SSR and tests in the tutorial.