mrkeyoor.com_
Tue 22 Sept 18:51 UTC
npmWeb Frontendupdated 22 Sept 2026

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.

Verdict

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

Lab card: what happened when we installed nanostoresScreenshot of nanostores documentation
Install✓ · 0.5s1 package on disk · 1 MB
ImportESM import works · require() works · ESM package with exports map
Browser2.2 KBgzipped (4.9 KB minified), bundled with esbuild
TypesTypeScript types bundled
Known vulns00 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.

API stability4/5The core vocabulary stays small: atom, map, computed, batched, batch, effect, subscribe, listen, and lifecycle hooks cover the main workflows. Version 1.5 extended stores with eq and eqKey rather than replacing existing update calls. Packaging remains a compatibility boundary because the current package is ESM and limits supported Node lines, while adapters and smart stores have independent versions that teams must coordinate.
Docs5/5The README explains immediate and delayed subscriptions, shallow maps, selected-key listeners, lazy cleanup timing, computed versus batched updates, custom equality, effects, explicit batching, async task tracking, SSR, tests, and adapters for many frameworks. It also lists separate packages for features outside core. The SSR example is useful but does not solve request isolation, so server teams still need an application-specific store factory or scoping plan.
Maintenance5/5Version 1.5.0 shipped on August 15, 2026 with custom comparison hooks and listener fixes; 1.5.1 and 1.5.2 followed within five days to repair key listening and declarations. GitHub showed a push on August 20, 2026, 7,578 stars, 21 open issues and pull requests, and an unarchived repository. Size Limit checks and bundled declarations indicate that package weight and typing remain release concerns.
Ecosystem4/5Adapters and documented contracts cover React, React Native, Preact, Vue, Svelte, Solid, Lit, Angular, Alpine, Web Components, and plain JavaScript. Companion packages add persistence, routing, async stores, media queries, SQL, queries, internationalization, and deep maps. The npm endpoint counted 7,855,447 downloads in the latest week. Breadth is real, but installing several small packages increases version and ownership decisions.

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.
Skip it if

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 = equal

eq 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

PackageRegistryPick it when
zustandnpmChoose it for a React-centered hook store with selectors and middleware.
jotainpmChoose it when React-only atom composition and Suspense integration are desired.
valtionpmChoose 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.