nanostores
Nano Stores is a small, framework-neutral state manager built around many independent atoms, shallow maps, and computed stores. The core package has no runtime dependencies and can run in vanilla JavaScript; separate bindings connect the same stores to React, Preact, Vue, Svelte, Solid, Lit, Angular, and Alpine. Stores can start work only while observed, batch writes, expose lifecycle events, and track async tasks for tests or server rendering. It favors direct store functions over reducers, actions, or a central state tree.
One of the best tiny choices for atomic state shared across frameworks, especially when lazy lifecycles matter. Skip it if you need old Node support, deep document updates, or batteries-included persistence and query tooling.
Use it if
- You want shared state that can survive a gradual migration between UI frameworks or power both framework and vanilla code
- Your state splits naturally into small atoms and derived values rather than one deeply nested application tree
- Bundle cost matters and you want a dependency-free, tree-shakable core with optional framework bindings
- Stores need lazy setup and cleanup for timers, subscriptions, media queries, or network connections
- Your runtime includes Node 18 or CommonJS-only tooling: version 1.4.2 declares Node ^20 or >=22, sets type=module, and exports only an ESM entry
- You want persistence, routing, remote-query caching, async computed state, or developer tooling in the core package: the README sends each of these to a separate Nano Stores or community package
- Your state is one large nested document with frequent deep updates: the built-in map is explicitly one level deep, and the project points deep state users to @nanostores/deepmap
- Your team wants reducer history, action inspection, and a mature time-travel workflow: core Nano Stores provides lifecycle events and an optional logger, not Redux-style centralized tooling
- You need request-isolated server state without designing that boundary: stores are ordinary mutable module objects, and the SSR example directly sets shared stores before rendering, so a long-lived server must prevent state from leaking between concurrent requests
Setup reality
The core install is genuinely small: npm install nanostores, no runtime dependencies, included TypeScript declarations, and an ESM-only package. The first compatibility check is Node: 1.4.2 accepts Node 20.x or 22 and newer, not Node 18 or 21. UI projects need a second package such as @nanostores/react, @nanostores/vue, or @nanostores/preact; each framework has its own useStore shape, and Svelte can use the store contract directly while its runes API uses another adapter. Remote fetching, localStorage persistence, routing, media queries, query caching, deep maps, and dev logging are separate packages, so the tiny core number does not describe a feature-rich app's final dependency set. Store modules are mutable singletons by default. In a browser that is convenient; in SSR it means you must initialize per-request values carefully and avoid concurrent requests sharing user state. allTasks only waits for work registered through task, and lazy onMount initialization starts only after a listener mounts the store. Cleanup is delayed by one second after the last subscriber, which prevents mount flicker but can surprise fake-timer tests. subscribe fires immediately with oldValue undefined, while listen waits for a change. Map setKey only handles shallow keys; setting an optional key to undefined removes it. React and other integrations rerender through their adapter, so importing a store and calling get inside render will not subscribe. Testing lazy stores usually requires keepMount and cleanStores. None of this is hard, but the library rewards teams that keep actions next to stores and make lifecycle ownership explicit.
Patterns
Create and update a typed atomcreate-atom
import { atom } from 'nanostores'
export const $count = atom<number>(0)
export function increment() {
$count.set($count.get() + 1)
}get reads a snapshot but does not subscribe. UI code should use its framework adapter so future changes rerender.
Subscribe and clean up in vanilla JavaScriptsubscribe-in-vanilla-js
const unsubscribe = $count.subscribe((value, oldValue) => {
counter.textContent = String(value)
})
// when the owning view is removed
unsubscribe()subscribe calls the callback immediately, with oldValue undefined on that first call. listen only runs after a later change.
Update one key in a map storestore-shallow-object
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)Maps are designed for one level of keys. Setting an optional key to undefined removes it; use a deep-map package for path-based nested updates.
React only to selected map keyslisten-to-map-key
import { listenKeys } from 'nanostores'
const unbind = listenKeys($profile, ['name'], (value, oldValue, changed) => {
console.log(changed, oldValue.name, value.name)
})listenKeys waits for changes. subscribeKeys also runs immediately, where oldValue and changed are undefined.
Derive a value from multiple storesderive-computed-store
import { computed } from 'nanostores'
export const $visibleTodos = computed(
[$todos, $filter],
(todos, filter) => todos.filter(todo =>
filter === 'all' ? true : todo.done === (filter === 'done')
)
)computed recalculates for every dependency update. Use batched when several related writes should produce only one derived update at the end of the tick.
Notify once after related writesbatch-store-writes
import { batch } from 'nanostores'
batch(() => {
$firstName.set('Ada')
$lastName.set('Lovelace')
})Listeners and effects run at most once after the outermost batch. For batched map key changes, the listener's changed argument is undefined.
Start work only while a store is observedmanage-lazy-lifecycle
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 runs after a one-second delay once the final listener unsubscribes. Keep browser globals inside onMount so importing the module during SSR stays safe.
Run and clean up a multi-store effectrun-reactive-effect
import { effect } from 'nanostores'
const cancel = effect([$enabled, $interval], (enabled, delay) => {
if (!enabled) return
const id = setInterval(sendHeartbeat, delay)
return () => clearInterval(id)
})
// later
cancel()The callback runs immediately and on changes. Its cleanup runs before the next execution, while cancel removes the effect completely.
Render a store in Reactuse-store-in-react
import { useStore } from '@nanostores/react'
import { $profile } from './stores/profile.js'
export function Header() {
const profile = useStore($profile)
return <header>Hello, {profile.name}</header>
}Install @nanostores/react separately. Calling $profile.get() in the component would read once without subscribing React to updates.
Register async initialization for SSR and teststrack-async-task
import { atom, onMount, task } from 'nanostores'
export const $post = atom(null)
onMount($post, () => {
task(async () => {
$post.set(await fetch('/api/post/1').then(r => r.json()))
})
})Only work wrapped in task is visible to allTasks. A listener must mount the lazy store before its task begins.
Wait for store tasks before server renderingprepare-server-render
import { allTasks } from 'nanostores'
const unbind = $post.listen(() => {})
await allTasks()
const html = renderToString(<App />)
unbind()Module-level stores are shared by the server process. Create an isolation strategy for user-specific values before handling concurrent requests.
Mount and clean a lazy store in teststest-lazy-store
import { cleanStores, keepMount } from 'nanostores'
afterEach(() => {
cleanStores($profile)
})
it('starts anonymous', () => {
keepMount($profile)
expect($profile.get()).toEqual({ name: 'Anonymous' })
})keepMount activates onMount logic without a UI subscriber. cleanStores forces cleanup and prevents store state or timers from crossing tests.
Alternatives
| Package | Registry | Pick it when |
|---|---|---|
| zustand | npm | A React-first team wants a hook-oriented store, selectors, middleware, and a larger integration ecosystem |
| jotai | npm | React is the only target and atom composition should follow React's component and Suspense model |
| valtio | npm | Mutable proxy objects feel more natural than explicit atom set calls in a React application |
| redux | npm | A large team values centralized actions, middleware, replayable state changes, and established debugging conventions |