Nanostores: Ultra-Tiny State Manager for Any Framework
AI TOOLS March 10, 2026, 11:30 p.m.

Nanostores: Ultra-Tiny State Manager for Any Framework

State management can feel like a maze of libraries, each promising speed, simplicity, or universality. In practice, you often end up juggling reducers, contexts, and a dozen tiny utilities just to keep a UI in sync. Enter Nanostores – an ultra‑tiny, framework‑agnostic state manager that fits in a single kilobyte and still powers complex apps. In this article we’ll unpack how Nanostores works, walk through real‑world examples, and share pro tips to make it a first‑class citizen in any stack.

What Makes Nanostores Different?

Nanostores is built around a single concept: a store that holds a value and notifies subscribers whenever that value changes. Unlike Redux or MobX, there is no boilerplate, no reducers, and no need for a global store hierarchy. The library’s tiny footprint (≈1 KB gzipped) means it won’t bloat your bundle, and its API works the same way in React, Vue, Svelte, or even plain JavaScript.

Because stores are plain objects with a set method and a subscribe callback, they can be inspected, serialized, or mocked with zero friction. This simplicity translates into faster development cycles, easier testing, and fewer runtime surprises.

Core API – The Building Blocks

The core API consists of three functions: atom, computed, and map. atom creates a mutable store, computed derives new values from one or more stores, and map lets you transform an object store into a collection of primitive stores.

All stores expose the same three methods:

  • set(value) – updates the store’s value and notifies subscribers.
  • get() – returns the current value synchronously.
  • subscribe(callback) – registers a listener that receives the new value on every change.

Because subscribe returns an unsubscribe function, cleaning up listeners is straightforward – a crucial feature for component‑based frameworks that mount and unmount frequently.

Installation and a Minimal Example

Getting started is as easy as installing a single npm package:

npm i nanostores

Below is a minimal “counter” store that demonstrates the core API. The example uses plain JavaScript, but the same code works in TypeScript with minimal changes.

import { atom } from 'nanostores';

// Create a store with an initial value of 0
export const $counter = atom(0);

// Helper functions to mutate the store
export const increment = () => $counter.set($counter.get() + 1);
export const decrement = () => $counter.set($counter.get() - 1);

Any component can now subscribe to $counter and react instantly to changes. Because the store is just an object, you can also log its value directly in the console for quick debugging.

Reactive Stores in Action – React Integration

React developers love hooks, and Nanostores offers a tiny hook called useStore that bridges the gap. The hook subscribes to a store, triggers a re‑render on change, and cleans up automatically.

import React from 'react';
import { useStore } from '@nanostores/react';
import { $counter, increment, decrement } from './counterStore';

export function Counter() {
  const count = useStore($counter); // Reactively reads the store

  return (
    <div>
      <h3>Count: {count}</h3>
      <button onClick={increment}>+

The component stays lean – no useEffect or manual subscription logic. The same pattern works in Vue with useStore from @nanostores/vue, or in Svelte using the $store syntax.

Derived Stores – Keeping Logic Out of Components

Derived stores (created with computed) let you encapsulate business logic away from UI code. For instance, you might want a boolean flag that indicates whether the counter is even.

import { computed } from 'nanostores';
import { $counter } from './counterStore';

export const $isEven = computed([$counter], (count) => count % 2 === 0);

Components can now subscribe to $isEven just like any other store, without re‑implementing the modulus check. This separation makes unit testing trivial – you simply feed values into the source store and assert the derived output.

Mapping Complex Objects – The map Utility

When you need to manage a collection of items (e.g., a list of products), map transforms an object store into a set of per‑item stores. This granular approach avoids unnecessary re‑renders because only the affected item’s store notifies its subscribers.

import { atom, map } from 'nanostores';

// A store that holds a dictionary of product objects
export const $products = atom({
  1: { id: 1, name: 'Laptop', price: 1200 },
  2: { id: 2, name: 'Headphones', price: 150 },
});

// Create a map that gives us a store per product ID
export const $productMap = map($products);

Now a component that displays a single product can subscribe to $productMap.get(1) and will only re‑render when that specific product changes, not when any other product updates.

Real‑World Use Case #1 – E‑Commerce Shopping Cart

An online store typically needs a cart that persists across pages, updates totals instantly, and syncs with a backend. Nanostores can handle all of these requirements with just a few stores.

  • $cartItems – an atom holding an array of item IDs and quantities.
  • $productMap – as shown earlier, provides fast lookup of product details.
  • $cartTotal – a computed store that sums the price × quantity for each cart entry.
import { atom, computed } from 'nanostores';
import { $productMap } from './productsStore';

// Cart items: [{ id: 1, qty: 2 }, { id: 2, qty: 1 }]
export const $cartItems = atom([]);

// Add an item to the cart
export const addToCart = (id, qty = 1) => {
  const items = $cartItems.get();
  const index = items.findIndex(item => item.id === id);
  if (index >= 0) {
    items[index].qty += qty;
  } else {
    items.push({ id, qty });
  }
  $cartItems.set([...items]); // trigger update
};

// Compute total price
export const $cartTotal = computed([$cartItems, $productMap], (items, map) => {
  return items.reduce((sum, { id, qty }) => {
    const product = map.get(id).get();
    return sum + product.price * qty;
  }, 0);
});

In a React component you can now read $cartTotal directly, and the UI will update instantly whenever the user adds or removes items. Because each product’s data lives in its own store, price changes from a promotion only affect the relevant items.

Real‑World Use Case #2 – Live Chat Message Feed

Chat applications need low‑latency updates and a way to prune old messages without disrupting the UI. Nanostores’ immutable approach to arrays makes it easy to prepend new messages while keeping a fixed length.

import { atom } from 'nanostores';

// Store holds the latest 50 messages
export const $messages = atom([]);

// Add a new incoming message
export const pushMessage = (msg) => {
  const updated = [msg, ...$messages.get()].slice(0, 50);
  $messages.set(updated);
};

Clients can subscribe to $messages and render a scrollable list. When the server pushes a new message via WebSocket, you simply call pushMessage. The UI updates in under 10 ms on modern browsers, thanks to the tiny diffing cost of Nanostores.

Real‑World Use Case #3 – Dashboard Widgets with Independent Refresh

Enterprise dashboards often contain widgets that fetch data at different intervals. Using a separate atom for each widget’s payload prevents a single widget’s refresh from causing a full‑page re‑render.

import { atom } from 'nanostores';

// Widget A fetches every 5 seconds
export const $widgetA = atom({ loading: true, data: null });
setInterval(async () => {
  const resp = await fetch('/api/widgetA');
  const data = await resp.json();
  $widgetA.set({ loading: false, data });
}, 5000);

// Widget B fetches every 30 seconds
export const $widgetB = atom({ loading: true, data: null });
setInterval(async () => {
  const resp = await fetch('/api/widgetB');
  const data = await resp.json();
  $widgetB.set({ loading: false, data });
}, 30000);

Each widget component subscribes only to its own store, so the UI remains responsive even as background fetches continue. This pattern scales gracefully to dozens of widgets without increasing bundle size.

Performance and Bundle Size – Why “Ultra‑Tiny” Matters

Nanostores ships as a single ES module with tree‑shakable exports. In a typical React app, the added bundle weight is under 1 KB after gzip compression, which is negligible compared to UI libraries or CSS frameworks. Moreover, because stores are plain objects, the JavaScript engine can inline them, resulting in fewer allocations and faster garbage collection.

Benchmarks from the official repo show that a simple atom can handle >100 k updates per second on a mid‑range laptop, outpacing many heavier state managers that rely on immutable data structures. This raw speed translates into smoother animations and less UI jitter, especially on mobile devices.

Pro Tips for Production‑Ready Nanostores

Tip 1 – Keep stores shallow. Deeply nested objects force you to replace the entire reference on every change, which defeats the purpose of fine‑grained subscriptions. Use map or separate atoms for nested data.

Tip 2 – Persist selectively. For data that must survive page reloads (e.g., auth tokens), subscribe once and write to localStorage. Avoid persisting large collections; instead, store only identifiers and rehydrate on demand.

Tip 3 – Leverage TypeScript. Nanostores ships with full typings. Declaring the shape of your atoms early catches mismatched payloads before they reach runtime, especially valuable in large teams.

Testing Nanostores – No Mocking Required

Because stores expose get and set, you can test them in isolation without any framework-specific utilities. A typical Jest test for the cart total looks like this:

import { $cartItems, $cartTotal, addToCart } from './cartStore';
import { $productMap } from './productsStore';

test('cart total reflects added items', () => {
  // Mock product data
  $productMap.set({
    1: { get: () => ({ price: 100 }) },
    2: { get: () => ({ price: 250 }) },
  });

  addToCart(1, 2); // 2 × $100
  addToCart(2, 1); // 1 × $250

  expect($cartTotal.get()).toBe(450);
});

The test manipulates the stores directly and asserts the computed value. No rendering, no DOM, and no asynchronous waiting – just pure JavaScript logic.

Migrating Existing Apps to Nanostores

If you already use Redux, MobX, or Context API, migration can be incremental. Start by extracting independent pieces of state into atoms, then replace the old provider with useStore calls. Because Nanostores does not enforce a global store, you can keep legacy code running side‑by‑side until the transition is complete.

A common pattern is to keep the old reducer for legacy actions while using new atoms for feature‑specific data. Over time, as the old reducer shrinks, you can safely delete it, resulting in a leaner codebase and fewer moving parts.

Advanced Patterns – Combining Stores with Async Logic

Nanostores does not prescribe a specific async model, but you can wrap promises inside atoms to represent loading states. The pattern below shows a generic fetchStore helper that returns an atom with { loading, error, data } shape.

import { atom } from 'nanostores';

export const fetchStore = (url) => {
  const $store = atom({ loading: true, error: null, data: null });

  fetch(url)
    .then(res => res.json())
    .then(data => $store.set({ loading: false, error: null, data }))
    .catch(err => $store.set({ loading: false, error: err, data: null }));

  return $store;
};

// Usage
export const $userProfile = fetchStore('/api/user');

Components can now render three states (loading, error, data) without any extra boilerplate. Because the store updates only once per request, you avoid the “stale closure” problems that sometimes arise with React’s useEffect.

Server‑Side Rendering (SSR) Support

Nanostores works seamlessly with SSR because stores are plain objects that can be serialized. After rendering on the server, you can embed the store’s state in a <script> tag and hydrate the client side by calling set with the preloaded data.

// Server side (e.g., Next.js getServerSideProps)
export async function getServerSideProps() {
  const $counter = atom(0);
  $counter.set(42); // pre‑populate

  return {
    props: {
      initialCounter: $counter.get(),
    },
  };
}

// Client side hydration
import { atom } from 'nanostores';
export const $counter = atom(window.__INITIAL_DATA__.initialCounter);

This approach eliminates the “flash of default state” that can occur with other libraries that rely on asynchronous hydration.

Interoperability with Other State Libraries

Sometimes you need to bridge Nanostores with an

Share this article