A workspace showing small connected store nodes on a laptop, with a large red Maine Coon cat resting nearby.

Nano Stores: A State Manager So Small You Can Measure It in Bytes

The Orange Cat
The Orange Cat

If you have ever felt that reaching for Redux to flip a single boolean is a bit like renting a moving truck to carry a sandwich, Nano Stores is going to feel refreshing. It is a state manager that fits in a few hundred bytes, has zero runtime dependencies, and was built around a deliberately unusual idea: instead of one big central store full of selectors, you write many small, independent stores, each one its own importable value. Your bundler then tree-shakes away anything a component does not touch.

The library comes from Andrey Sitnik, the author of PostCSS, Autoprefixer, and Logux, and that pedigree shows in its obsessive attention to size and ergonomics. But the headline feature is not really the byte count. It is that a Nano Store is just a plain JavaScript value living at module scope, completely independent of any framework. That makes it uniquely good at one job that most state libraries struggle with: sharing live state across independently hydrated components, even when those components are written in different frameworks. If you build with Astro islands, this is the pattern the official docs reach for.

Why Such Tiny Stores

Nano Stores ships first-class adapters for React, Preact, Vue, Svelte, Solid, Lit, Angular, Alpine, and vanilla web components. A handful of capabilities make it stand out:

  • Atomic by design. Each piece of state is its own store you import where you need it. Components only re-render when the specific store they read actually changes, with no selector functions or equality checks.
  • Genuinely tiny and tree-shakable. The README quotes a footprint between roughly 294 and 831 bytes depending on which helpers you import. Stores you never use cost zero bytes.
  • Framework-agnostic and provider-free. There is no <Provider> to wrap your app in. The store is a module value, so the same store works in React, in Svelte, and in a plain DOM script simultaneously.
  • Lazy stores. A store can wait until something actually subscribes before it fetches data or opens a websocket, then clean up when nobody is listening.
  • Logic out of components. The philosophy encourages putting data loading, validation, and timers inside stores so they stay reusable and testable, leaving your components thin.

Getting It Into Your Project

The core package is nanostores. For React you also add the official binding, @nanostores/react.

npm install nanostores @nanostores/react

Or with yarn:

yarn add nanostores @nanostores/react

The core has no runtime dependencies, and the React adapter is just as light. One convention worth adopting from the start: store variables are prefixed with $ (for example $counter, $profile) so that stores are visually distinct from plain values. The docs and the community lean on this heavily, so it is worth following.

The Building Blocks

Atoms: One Value, Anywhere

An atom is the simplest store. It holds any value at all, whether a number, a string, an array, or a whole object, and gives you a tiny read/write/subscribe surface.

import { atom } from 'nanostores'

export const $counter = atom(0)

$counter.get()                    // read the current value
$counter.set($counter.get() + 1)  // write a new value

const unbind = $counter.listen((value) => {
  console.log('counter is now', value)
})

listen() fires only when the value changes. If you want a callback that also fires immediately with the current value, use subscribe() instead. The key thing to internalize is that an atom is a free-standing value: you exported it from a module, so anything that imports it shares the exact same instance.

Maps: Objects You Edit Key by Key

When your state is a single-level object and you want to update individual keys without replacing the whole thing, reach for map.

import { map } from 'nanostores'

export const $profile = map({ name: 'anonymous', age: 0 })

$profile.setKey('name', 'Kazimir Malevich')
$profile.setKey('age', 38)

setKey notifies subscribers of the change while reusing the rest of the object, which keeps updates cheap. For deeply nested objects with path-based keys, there is a companion package, @nanostores/deepmap, that extends the same idea.

Computed: Derived State for Free

A computed store derives its value from one or more other stores and recalculates automatically when any dependency changes.

import { atom, computed } from 'nanostores'

export const $users = atom([
  { name: 'Ada', isAdmin: true },
  { name: 'Linus', isAdmin: false },
])

export const $admins = computed($users, (users) =>
  users.filter((user) => user.isAdmin)
)

const $first = atom('Grace')
const $last = atom('Hopper')
export const $fullName = computed(
  [$first, $last],
  (first, last) => `${first} ${last}`
)

If you have several dependencies that tend to change together in the same tick, batched(...) works just like computed but coalesces those synchronous changes into a single recompute, avoiding wasted intermediate calculations.

Wiring Stores Into React

The entire React story is one hook: useStore. There is no provider, no context, and no boilerplate. You define your stores in their own module, alongside any functions that mutate them, then read them in components.

// stores/profile.ts
import { map } from 'nanostores'

export const $profile = map({ name: 'anonymous' })

export function setName(name: string) {
  $profile.setKey('name', name)
}
// Header.tsx
import { useStore } from '@nanostores/react'
import { $profile } from '../stores/profile'

export function Header() {
  const profile = useStore($profile)
  return <header>Hi, {profile.name}</header>
}

Computed stores work exactly the same way through useStore, since they are just stores too:

import { atom, computed } from 'nanostores'
import { useStore } from '@nanostores/react'

const $todos = atom([{ done: true }, { done: false }])
const $remaining = computed($todos, (todos) =>
  todos.filter((todo) => !todo.done).length
)

function RemainingBadge() {
  const remaining = useStore($remaining)
  return <span>{remaining} left</span>
}

Only components that call useStore on a store that actually changed will re-render. That selective behavior comes for free from the atomic model. Notice too that those exported setName-style functions are the closest thing Nano Stores has to "actions." The library treats actions as a plain convention, ordinary exported functions that mutate stores, rather than a dedicated API primitive. One caveat for render code: prefer useStore (or subscribe) inside components, and reserve .get() for tests and one-off reads. Calling .get() during render will not make your component reactive.

Going Further

Lazy Stores That Do Work Only When Watched

This is where Nano Stores starts to feel clever. The onMount hook runs a callback only while a store has at least one active subscriber, and the function it returns is a cleanup that runs when the last subscriber leaves. That lets you build stores that fetch data or open connections lazily, on demand, instead of on app boot.

import { atom, onMount, task } from 'nanostores'

export const $posts = atom<Post[]>([])

onMount($posts, () => {
  task(async () => {
    const response = await fetch('/api/posts')
    $posts.set(await response.json())
  })

  return () => {
    // cleanup: cancel timers, close sockets, abort requests
  }
})

A nice detail: there is a built-in delay of about one second before a store actually unmounts, so quickly remounting a component (think route transitions) does not tear down and rebuild your subscriptions needlessly. In tests or during SSR you sometimes want to override this laziness; keepMount(store) forces a store to stay mounted, and cleanStores(...stores) resets state between tests.

The task() wrapper above is not decoration. It registers in-flight async work so that server rendering and tests can wait for everything to settle:

import { allTasks } from 'nanostores'

await allTasks() // resolves once every task()-wrapped operation has finished

Validation, Effects, and Lifecycle Hooks

Stores can intercept their own changes. The onSet hook fires before a new value is applied, which is the natural place to validate input and reject bad writes by calling abort().

import { onSet } from 'nanostores'

onSet($profile, ({ newValue, abort }) => {
  if (!newValue.name.trim()) abort()
})

Related hooks onStart, onStop, and onNotify give you finer control over a store's lifecycle. For coordinating side effects across several stores at once, effect subscribes to multiple atoms and supports cleanup, which is perfect for things like timers driven by configuration state:

import { effect } from 'nanostores'

const cancel = effect([$enabled, $interval], (enabled, interval) => {
  if (!enabled) return
  const id = setInterval(sendPing, interval)
  return () => clearInterval(id)
})

One Store, Many Islands

The standout use case deserves its own moment. In Astro, each interactive component is an island that hydrates independently, which means React context cannot reach across two separate islands. Nano Stores sidesteps this entirely because the store is a module-level value, not something bound to a component tree.

---
import CartButton from './CartButton.jsx'
import CartCount from './CartCount.jsx'
---
<CartButton client:load />
<CartCount client:load />

Both components simply import { $cart } from '../stores/cart' and stay perfectly in sync. The two islands could even be written in different frameworks, one in React and one in Svelte, and the shared store would still keep them aligned. This is precisely why Astro's official guide on sharing state between islands recommends Nano Stores as the pattern of choice.

A Whole Ecosystem of Tiny Companions

The core stays small, and optional packages cover the rest. @nanostores/persistent syncs a store to localStorage, @nanostores/router models client-side routing as a store, @nanostores/query brings React-Query-style data fetching and caching on top of stores, @nanostores/i18n handles internationalization, and @nanostores/logger gives you dev-time visibility into store changes, a handy substitute for the time-travel devtools you would get from a single central store.

Should You Reach For It

Nano Stores is at its best when you are building with islands architecture and need shared state across independently hydrated components, when you want the smallest possible state layer with zero dependencies, or when you want one state model that works identically across multiple frameworks. Its atomic, many-small-stores discipline rewards splitting state into focused units; cramming everything into one giant map would throw away the selective-render benefit that makes it special.

It is a different mental model from Zustand's single store with selectors, or Valtio's proxy-based mutation, and it trades the formal structure and rich devtools of Redux for being orders of magnitude smaller. But if the philosophy of keeping logic in tiny, testable, framework-independent stores resonates with you, Nano Stores delivers it in a package so small you can genuinely measure it in bytes, and once you have written a few atoms, you may find yourself wondering why state management ever felt heavier than this.