nanostores review
Nano Stores is a framework-neutral state library based on small atoms, shallow object maps, derived values, effects, and lazy mount lifecycles. The core works in vanilla JavaScript; separate adapters subscribe React, Vue, Preact, Solid, Angular, Lit, Alpine, and Svelte code to the same stores. Version 1.5 added custom eq and eqKey comparisons, kept later listeners running when one throws, and repaired deep-path key listening. Versions 1.5.1 and 1.5.2 fixed listener and TypeScript declarations. Our complete import bundled to 2.2 KB gzipped with no dependencies.
Nano Stores is a good small choice for state that must cross framework boundaries or own a lazy subscription lifecycle. Skip it when the application needs one batteries-included state platform, old Node versions, or deep shared documents without companion packages.
We installed it
| Install | ✓ · 0.5s | 1 package on disk · 1 MB |
| Import | ✓ | ESM import works · require() works · ESM package with exports map |
| Browser | 2.2 KB | gzipped (4.9 KB minified), bundled with esbuild |
| Types | ✓ | TypeScript types bundled |
| Known vulns | 0 | 0 critical · 0 high · 0 moderate · 0 low (npm audit) |
Answers from our run
Does nanostores install cleanly?
Yes. In a fresh container with an empty cache, npm install nanostores finished in 0.5s, leaving 1 package and 1 MB on disk. npm audit reported no known vulnerabilities.
How much does nanostores add to a browser bundle?
2.2 KB gzipped (4.9 KB minified) when the whole package is bundled for the browser with esbuild. Importing only part of it is usually smaller.
Does nanostores work with both ESM and CommonJS?
Yes. Both import 'nanostores' and require('nanostores') worked in Node 22 in our run. The package is published as ESM with an exports map.
Does nanostores include TypeScript types?
Yes, type declarations ship inside the package, so no @types install is needed.
nanostores or zustand: which should you use?
zustand: Choose it for a React-centered hook store with selectors and middleware. Nano Stores is a good small choice for state that must cross framework boundaries or own a lazy subscription lifecycle.
When should you not use nanostores?
Node 18 or 21 is part of the runtime matrix. Version 1.5.2 permits Node 20 or Node 22 and newer.
Use it if
- Shared state must outlive a UI-framework migration or feed framework and plain JavaScript views together.
- Application state divides cleanly into independent atoms and small derived stores.
- Subscriptions such as timers, media queries, or sockets should start only while a store has listeners.
- You want a small typed core and are willing to install persistence, routing, fetching, or framework adapters separately.
- Node 18 or 21 is part of the runtime matrix. Version 1.5.2 permits Node 20 or Node 22 and newer.
- You need deep object updates in the core API. map changes one level; the project sends path-based nested state to @nanostores/deepmap.
- Persistence, remote-query caching, routing, async computed state, and browser devtools must arrive in one package. Nano Stores splits these into companions.
- Your team expects reducer logs, action replay, and time-travel debugging. The core has events and a separate logger, not Redux's centralized workflow.
- User-specific stores will live as module singletons in a concurrent SSR server without request isolation. Directly setting a global store before render can leak state between requests.
Setup reality
We installed nanostores 1.5.2 in a fresh Node 22 Bookworm container. npm completed in 0.5 seconds, installed one package, and used 1 MB on disk. The package has no direct or peer dependencies and is 192 KB unpacked. It includes TypeScript declarations, uses the MIT license, and declares Node ^20.0.0 or >=22.0.0. npm audit found no known vulnerabilities at any severity. Both require and ESM import worked in our check, even though the package is ESM with an exports map.
Importing the whole package through esbuild produced 4.9 KB minified and 2.2 KB gzipped. A real UI also needs its adapter, such as @nanostores/react or @nanostores/vue. Persistence, routing, async computed values, media queries, deep maps, query caching, and logging are separate packages. Add the sizes and version constraints of the pieces you actually choose instead of applying the core result to the finished application.
Subscription behavior causes most first-day mistakes. subscribe calls immediately and supplies undefined as the old value on that call; listen waits for a change. get only reads a snapshot, so a React component must use the adapter's useStore hook to rerender. onMount starts lazy work after a listener appears, then waits one second after the last unsubscribe before cleanup. Tests with fake timers should mount and clean lazy stores explicitly.
Maps are shallow, and setKey with undefined removes an optional key. The new eq and eqKey hooks can suppress equal updates, but those comparison functions belong to the store and therefore affect every consumer. allTasks waits only for work registered with task, and lazy tasks do not start until a listener mounts the store. On a server, design per-request state ownership before setting authentication or profile values in module-level stores.
Patterns
Define an atom and action create-typed-atom
import { atom } from 'nanostores'
export const $count = atom<number>(0)
export function increment() {
$count.set($count.get() + 1)
}get reads the current value without subscribing a UI component.
Subscribe outside a framework subscribe-vanilla-js
const stop = $count.subscribe((value, oldValue) => {
counter.textContent = String(value)
})
// Dispose with the owning view
stop()subscribe runs immediately and oldValue is undefined on that first call. listen waits for the next change.
Change one map field update-shallow-map
import { map } from 'nanostores'
type Profile = { name: string; email?: string }
export const $profile = map<Profile>({ name: 'Anonymous' })
$profile.setKey('name', 'Ada')
$profile.setKey('email', undefined)A map is one level deep. Setting an optional field to undefined removes it.
Watch one map key listen-selected-keys
import { listenKeys } from 'nanostores'
const stop = listenKeys($profile, ['name'], (value, oldValue, changed) => {
console.log(changed, oldValue.name, value.name)
})listenKeys waits for a change. subscribeKeys also calls immediately with undefined oldValue and changed.
Filter from two stores derive-store-value
import { computed } from 'nanostores'
export const $visible = computed(
[$todos, $filter],
(todos, filter) => todos.filter(todo =>
filter === 'all' || todo.done === (filter === 'done')
)
)computed recalculates after each dependency notification. Use batched when updates can wait until the end of the tick.
Suppress equal array updates customize-equality
import equal from 'fast-deep-equal/es6'
import { computed } from 'nanostores'
export const $visibleIds = computed($posts, posts => posts.map(post => post.id))
$visibleIds.eq = equaleq is shared by every consumer of this store. It is new in 1.5 and does not suppress an explicit notify call.
Notify once for two writes batch-related-writes
import { batch } from 'nanostores'
batch(() => {
$firstName.set('Ada')
$lastName.set('Lovelace')
})Only the outermost batch flushes. A batched map listener receives undefined for changed because several keys may have moved.
Own a browser subscription create-lazy-store
import { atom, onMount } from 'nanostores'
export const $online = atom(false)
onMount($online, () => {
const update = () => $online.set(navigator.onLine)
update()
window.addEventListener('online', update)
window.addEventListener('offline', update)
return () => {
window.removeEventListener('online', update)
window.removeEventListener('offline', update)
}
})Cleanup waits one second after the last listener leaves. Keeping browser globals inside onMount also makes module import safer during SSR.
Clean up a store-driven timer run-reactive-effect
import { effect } from 'nanostores'
const cancel = effect([$enabled, $delay], (enabled, delay) => {
if (!enabled) return
const timer = setInterval(sendHeartbeat, delay)
return () => clearInterval(timer)
})The callback runs at setup and after changes. Its returned cleanup runs before another execution.
Subscribe a React component render-in-react
import { useStore } from '@nanostores/react'
import { $profile } from './profile.js'
export function Header() {
const profile = useStore($profile)
return <header>Hello, {profile.name}</header>
}Install @nanostores/react separately. Reading $profile.get() during render does not subscribe React.
Expose loading work to SSR register-async-task
import { atom, onMount, task } from 'nanostores'
export const $post = atom(null)
onMount($post, () => {
task(async () => {
$post.set(await fetch('/api/posts/1').then(r => r.json()))
})
})allTasks sees only promises registered through task, and this work starts only after the store is mounted.
Activate and reset a store in tests test-lazy-lifecycle
import { cleanStores, keepMount } from 'nanostores'
afterEach(() => cleanStores($profile))
it('starts anonymous', () => {
keepMount($profile)
expect($profile.get()).toEqual({ name: 'Anonymous' })
})keepMount runs lazy setup without a UI adapter. cleanStores prevents state and delayed cleanup from crossing test cases.
Alternatives
| Package | Registry | Pick it when |
|---|---|---|
| zustand | npm | Choose it for a React-centered hook store with selectors and middleware. |
| jotai | npm | Choose it when React-only atom composition and Suspense integration are desired. |
| valtio | npm | Choose it when mutable proxy objects fit the team's mental model better than explicit set calls. |
More web frontend guides
postcss · react · react-dom · tailwindcss · htmlparser2 · tailwind-merge · the whole shelf →
How this guide is made: grounded in the library's documentation, release notes, changelog, and issue history, on a fixed rubric — not a hands-on install of every release. The 50 most-downloaded entries are additionally install-verified in clean containers. Corrections: contact the desk.

