react
nanostores
design-patterns
clean-code
12m

Augmenting nanostores: designing a layer for better DX

Nanostores facade meme

Many developers know nanostores as a microscopic, brilliant reactivity engine (like Angular's signals). Its API is correct, but in day-to-day work it generates a fair amount of repetitive noise: separate imports, manually passing the store into a hook, hand-written resets. It's worth knowing how to design a thin layer that removes that noise. It's what separates teams that merely use a library from teams that get pleasure and predictability out of it.

This article shows how to enrich nanostores with better DX — without losing a single gram of the engine's reactivity. Step by step, we'll build enhanced atom, map, and computed with one consistent import point.

Here's the whole thing in code.

A note on the pattern's name. It's tempting to call it a "facade", but more precisely it's store augmentation: we add behavior and preserve the entire original interface. This is closer to a decorator than a facade — with the difference that we mutate the object in place instead of wrapping it. The real facade here only shows up as the single import point (Step 4), because that's where we actually merge two packages behind one entry. So let's stick to the names: augmentation is enriching the store, facade is the unified import.

Why this layer at all? Diagnosing the DX friction

Before we write a single line, let's name the problem. "Bare" nanostores combined with React forces a few small inconveniences that repeat hundreds of times:

// Raw API — it works, but it generates friction
import { atom } from 'nanostores';
import { useStore } from '@nanostores/react';

const $count = atom(0);

const Counter = () => {
  const count = useStore($count);   // 1. a second import + manually passing the store
  // ...
};

// 2. Reset? You write it by hand every single time:
const INITIAL = 0;
const $x = atom(INITIAL);
const resetX = () => $x.set(INITIAL);   // boilerplate that keeps coming back

// 3. Removing a key from a map? You have to remember the undefined trick.

Three concrete frictions to remove:

  1. A scattered import point: atom/map/computed from one package, useStore from another. The developer has to remember both.
  2. A hook detached from the store: useStore($count) — you have to import the hook and hand it the store. It's begging for $count to "know" how to render itself.
  3. Missing built-in yet constantly needed operations: reset, access to the initial value, key removal — needs that come back in every other store.

The anatomy of augmentation: Object.assign

Everything rests on a single technique: we take a ready store from nanostores and glue helper methods onto it via Object.assign. We don't wrap the store in a new object (that would break the reference and the types), we don't create a proxy — we modify the same store, adding functions to it.

The whole layer stands on two packages — the engine and its React integration:

import { useStore } from '@nanostores/react';
import {
  atom as nanoAtom,
  map as nanoMap,
  computed as nanoComputed,
  type PreinitializedWritableAtom,
  type MapStore,
  type Store,
  type StoreValue,
  type ReadableAtom,
} from 'nanostores';

We keep our additions in a separate interface, and the result type is a plain intersection: the original store & our methods. Thanks to satisfies, the object passed to Object.assign is itself checked against that interface — we don't lose the store's types, and we immediately see if any method got the wrong signature.

type EnhancedAtom<TValue> = {
  reset(): void;
  getInitial(): TValue;
  use(): TValue;
};

// Result = a real nanostores store + our methods
type Atom<TValue> = PreinitializedWritableAtom<TValue> & EnhancedAtom<TValue>;

export const atom = <TValue>(value: TValue): Atom<TValue> => {
  const $atom = nanoAtom<TValue>(value);   // 1. a real store from nanostores

  return Object.assign($atom, {            // 2. the SAME store, enriched
    reset() { $atom.set(value); },
    getInitial() { return value; },
    use() { return useStore($atom); },
  } satisfies EnhancedAtom<TValue>);
};

Why Object.assign and not "wrapping"?

  • It preserves the reference. The returned object is $atom. The original methods still work, and other stores (e.g. computed) can use it as a source without any adapters.
  • It preserves the types. The result type is PreinitializedWritableAtom<T> & EnhancedAtom<T> — IDE autocompletion shows both the nanostores API and our additions at once.
  • satisfies guards the contract. We verify the object with the methods against EnhancedAtom<T>, but it reaches Object.assign without widening the type — we get a compile error immediately if any method drifts.
  • Zero runtime overhead. We attach a few functions once, when the store is created. There's no intermediate layer on every operation.

That in-place mutation is exactly why we say "augmentation" and not "decorator": a classic decorator wraps an object and delegates; we enrich the original.

Step 1: atom

atom is the basic building block — a single value. We attach three methods to it, each addressing one friction from the diagnosis.

export const atom = <TValue, TStoreExt = object>(
  value: TValue,
): Atom<TValue> => {
  const $atom = nanoAtom<TValue, TStoreExt>(value);

  return Object.assign($atom, {
    /** Restores the initial value */
    reset() { $atom.set(value); },
    /** Returns the value the atom was created with */
    getInitial() { return value; },
    /** React hook — subscribes to changes and returns the current value */
    use() { return useStore($atom); },
  } satisfies EnhancedAtom<TValue>);
};
  • use() — a hook glued to the store. Instead of useStore($count) you write $count.use(). The hook "lives" where the data lives. The second import and the manual store hand-off disappear.
  • reset() — no more keeping INITIAL by hand. The initial value is closed over in the factory's closure, so reset() simply works.
  • getInitial() — access to the starting value. Useful, for example, to detect "was the form modified?" (current !== getInitial()).
  • The TStoreExt generic — it's just a pass-through for nanoAtom's second parameter, so that any nanostores store extensions still make it through our factory. In everyday use you never touch it.

Important note (DX vs. semantics): use() looks like an ordinary method, but it is a React hook. In exchange for the ergonomics ($store.use()), the method is bound by all of React's Rules of Hooks. For reads outside of render (e.g. in onClick), keep using the original .get().

Step 2: map — plus the operation the API is missing

map gives you reactivity at the key level (setKey). We add the same three methods as with atom, but also one new operation that nanostores itself doesn't expose ergonomically.

type Obj = Record<string | number | symbol, unknown>;

type EnhancedMap<TValue extends object> = {
  reset(): void;
  getInitial(): TValue;
  use(): TValue;
  removeKey<TKey extends keyof TValue>(key: TKey): void;
};

type Map<TValue extends object> = MapStore<TValue> & EnhancedMap<TValue>;

export const map = <TValue extends Obj>(value: TValue): Map<TValue> => {
  const $map = nanoMap<TValue>(value);

  return Object.assign($map, {
    reset() { $map.set(value); },
    getInitial() { return value; },
    use() { return useStore($map); },
    /** Removes a key from the map */
    removeKey<TKey extends keyof TValue>(key: TKey) {
      // eslint-disable-next-line @typescript-eslint/no-explicit-any
      $map.setKey(key as any, undefined as any);
    },
  } satisfies EnhancedMap<TValue>);
};

A small detail in the signature: the factory requires TValue extends Obj (that is, Record<string | number | symbol, unknown>), because nanoMap expects an object type. But that constraint only says "it must be an object" — the concrete TValue, e.g. { coupon: string; items: number }, keeps its exact shape. That's precisely why setKey stays strict (more on that in a moment).

  • removeKey() — naming a hidden trick. In raw nanostores, "removing" a key is done via setKey(key, undefined). That's tribal knowledge. We give the intent a name: removeKey('coupon') reads exactly the way it behaves.

Under the hood removeKey is setKey(key, undefined), and in nanostores that means an actual removal of both the value and the key. Listeners are notified just like on a regular change ($map.notify(oldMap, key)) — the reactivity model is preserved. If you rely somewhere on the key being present, remember: after removeKey it genuinely disappears from the object.

A digression about types: where TS detects keys, and where those anys come from

Let's come back to those two as anys.

The one-liner removeKey hides two interesting typing decisions. Let's look at them closely:

removeKey<TKey extends keyof TValue>(key: TKey) {
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
  $map.setKey(key as any, undefined as any);
}

1. A generic on the method = key detection at the call site

The type parameter <TKey extends keyof TValue> lives on the method, not just "somewhere up there" on the factory. That's not cosmetics — it's exactly this placement that makes key detection happen where the developer writes code, i.e. at the call:

const $cart = map({ coupon: 'NONE', items: 0 });

$cart.removeKey('coupon');   // ✅ TKey narrowed to the literal 'coupon'
$cart.removeKey('cupon');    // ❌ compile error + hint: 'coupon' | 'items'

If we wrote it more loosely as removeKey(key: keyof TValue), autocompletion would still show the union of keys. But we'd lose the narrowing to a specific literal (TKey = 'coupon'). And that pays off when you return something dependent on the key or chain methods together.

2. Where the any and that "weird" cast come from

If the public signature is fully typed, why does the inside drop down to any? Because we run into the deliberately strict setKey type from nanostores:

// nanostores/map — the real signature
setKey<Key extends AllKeys<Value>>(
  key: Key,
  value: Get<Value, Key> | ValueWithUndefinedForIndexSignatures<Value, Key>
): void

The second argument is the key part. ValueWithUndefinedForIndexSignatures literally means: undefined is allowed as a value only when the map has an index signature (Record<string, X>, { [k: string]: X }). A map with a fixed shape — like { coupon: string; items: number } — has no such signature. That's why value must be exactly string/number, and undefined is rejected by the type.

That directly explains both casts:

  • undefined as any — our intent ("remove the key") clashes with the setKey type for fixed-shape maps. The type deliberately doesn't let you erase a required key, because it would break the object's shape. TS is right to block us. We're the ones knowingly breaking that rule.

Under the hood removeKey calls setKey(key, undefined). In the nanostores implementation this doesn't end with undefined being stored — the library performs a delete on the object. That means the key really disappears from the map, and observers are notified exactly as on a regular change.

  • key as any — our TKey extends keyof TValue and the library's Key extends AllKeys<Value> are two open generics. Inside the method body TS can't prove that keyof TValue is assignable to AllKeys<Value> (a known limitation of its generic-to-generic relation), so the key needs a bridge too.

Why this is OK: both anys are confined to a single internal line. The public signature removeKey<TKey extends keyof TValue>(key: TKey) stays 100% typed. Callers never touch any — they get key hints and an error on a typo. It's an escape hatch exactly at the boundary where our intent diverges from the (rightly strict) library type. One ugly line inside so that hundreds of calls outside stay clean. No elaborate type acrobatics — pure pragmatism. You'll find similar solutions in many packages you use every day. Just a temporary compromise...

Step 3: computed — deliberately less

Here we deliberately add less. computed gets only use():

type EnhancedComputed<TValue> = {
  use(): TValue;
};

type Computed<TValue> = ReadableAtom<TValue> & EnhancedComputed<TValue>;

// Overload 1: a single source store
export function computed<TValue, TStore extends Store>(
  stores: TStore,
  cb: (value: StoreValue<TStore>) => TValue,
): Computed<TValue>;

// Overload 2: a tuple of source stores
export function computed<TValue, TStores extends [Store, ...Store[]]>(
  stores: TStores,
  cb: (...values: { [K in keyof TStores]: StoreValue<TStores[K]> }) => TValue,
): Computed<TValue>;

// Implementation
export function computed<TValue>(
  stores: Store | Store[],
  cb: (...values: unknown[]) => TValue,
): Computed<TValue> {
  const $computed = (
    Array.isArray(stores)
      ? nanoComputed(stores as [Store, ...Store[]], cb as (...v: StoreValue<Store>[]) => TValue)
      : nanoComputed(stores, cb as (v: StoreValue<Store>) => TValue)
  ) as ReadableAtom<TValue>;

  return Object.assign($computed, {
    use() { return useStore($computed); },
  } satisfies EnhancedComputed<TValue>);
}

This is the only primitive with overloads: it accepts either a single store or a tuple of stores, and the callback receives their values in the same order — with full typing for every argument. We enrich it minimally though, with a single use(), and the result is a ReadableAtom (read-only — because you don't set a computed by hand).

Why no reset() and getInitial()? Because computed has no state of its own — its value is derived from its sources. A "reset" would have nothing to reset: you reset the sources, and computed recalculates itself. So each primitive's API reflects its nature instead of copying the same set of methods everywhere.

computed doesn't compute anything until someone starts listening to it (subscribe/listen/use()) or until you read it via get().

Step 4: A single import point — and here comes the facade

The last friction — scattered imports — disappears once we export all three factories from one module. This is the only part that deserves the name facade: it unifies two packages (nanostores + @nanostores/react) behind one entry.

// supa-store.ts — the only entry point (all three factories in one module)
export const atom = /* ... */;
export const map = /* ... */;
export function computed(/* ... */) {
  /* ... */
}
// ❔ Before: two sources, two styles
import { atom, map, computed } from 'nanostores';
import { useStore } from '@nanostores/react';

// ✅ After: one source, one consistent style (and the hook comes "included" via .use())
import { atom, map, computed } from 'supa-store';

An example: three behaviors you have to feel

Augmentation doesn't change the engine's semantics — which is exactly why it's worth knowing them. The whole practical core comes down to three things, and this small example shows them all:

import { atom, computed } from 'supa-store';

const $price = atom(100);
const $qty   = atom(2);

// (1) computed is LAZY — this function has NOT run yet
const $total = computed([$price, $qty], (p, q) => {
  console.log('[compute]', p * q);
  return p * q;
});

// (2) subscribe wakes the computed up and fires IMMEDIATELY
$total.subscribe((v) => console.log('[sub]', v));
// -> [compute] 200
// -> [sub] 200
// (3) propagation is SYNCHRONOUS — the whole chain runs before the next line starts
$price.set(150);
// -> [compute] 300
// -> [sub] 300

console.log('done');   // only now

A curiosity: augmentation inherits the engine's pitfalls

Since the layer deliberately doesn't hide the semantics of nanostores, it also inherits its pitfalls. The best example: reset() holds the initial value through a closure — exactly the same reference you passed at the start.

export const atom = (value) => {
  const $atom = nanoAtom(value);
  return Object.assign($atom, {
    reset() { $atom.set(value); },   // <- this is STILL the same reference
  });
};

The problem: if the initial value is an object/array and you mutate it somewhere, reset() will restore you to the already-mutated version:

const initial = { filters: ['a'] };
const $state = atom(initial);

initial.filters.push('b');           // mutating the initial reference!
$state.set({ filters: ['x'] });
$state.reset();

console.log($state.get().filters);   // ['a', 'b']  — not ['a']!

This isn't a bug to be "fixed" by copying state inside (that would kill the layer's thinness). Instead, simply don't mutate the initial value, or use the approach shown below.

const makeInitial = () => ({ filters: ['a'] });
const $state = atom(makeInitial());
const hardReset = () => $state.set(makeInitial());   // a clean state every time

Augmentation doesn't change the behavior of the nanostores engine (reference comparison, laziness, synchronous propagation) — it simply inherits it.


Cheat sheet

DX friction (raw nanostores)SolutionTechnique
useStore($x) — a second import + manually passing the store$x.use()A hook glued to the store
Keeping INITIAL and set(INITIAL) by hand$x.reset() / $x.getInitial()The initial value in a closure
"Removing" a key via the setKey(k, undefined) trick (a real delete)$map.removeKey(k)Naming the intent + a generic on the method
Imports from two packages, two stylesimport { atom, map, computed } from 'supa-store'A single entry point (that's the facade)

Summary

Augmenting nanostores is an exercise in restraint. One technique (Object.assign) adds exactly as much as needed — use, reset/getInitial, removeKey — and not a gram more. All of the engine's power stays untouched: the laziness of computed, synchronous propagation, key-level reactivity. It's just worth calling things by their proper names: enriching the store is augmentation (the decorator's cousin), and one shared entry is a facade. A good boundary between "what we add" and "what we don't touch" is what makes working with state predictable — and simply more comfortable.

Why doesn't nanostores do this itself?

The question suggests itself: if use(), reset(), or removeKey() are so convenient, why doesn't the library ship them out of the box? It's not an oversight — these are deliberate design decisions, and each has a concrete reason:

  • An obsession with size. nanostores advertises numbers on the order of ~265 bytes per store — that's its trademark. Adding reset, getInitial, and removeKey to every store would grow that baseline cost for everyone, including those who never use them. An opt-in layer (like ours) shifts that cost to where it's actually needed.

  • Framework agnosticism. The core can't contain use(), because use() is a React hook, and nanostores also supports Vue, Svelte, Solid, and vanilla JS. If the core knew about React, it would stop being universal. That's why the React integration lives in a separate package, @nanostores/react — and why it's up to us, in the application layer, to glue the two together via .use().

  • Tree-shaking and paying only for what's yours. By keeping helpers outside the core, the library lets the bundler drop everything you don't import. Baking them in permanently would take that away.

  • A minimal, orthogonal core. nanostores deliberately provides primitives (atom, map, computed), not ready-made application patterns. reset or "detect whether the form was modified" are conventions of your domain, not part of the reactivity model — and it's better that you decide their shape.

  • The strict setKey type is a feature, not a bug. The rigor that forces us into as any inside removeKey protects fixed-schema maps by default from accidentally erasing a required key. The library prefers to be strict and safe, leaving the decision to knowingly break that rule to the author of the layer above (a dynamic object, for example :X).

In other words: nanostores doesn't do what we do because its job is to be the smallest possible universal engine. Our augmentation doesn't "fix" the library — it adds convenience on its terms. There, where we already know our framework, our domain, and our trade-offs.

Author avatar
About Authorpolubinski.dev

Currently engaged in mentoring, sharing insights through posts, and working on a variety of full-stack development projects. Focused on helping others grow while continuing to build and ship practical solutions across the tech stack. Visit my Linkedin or my site for more 🌋🤝

Bullshit Meter

😎
0.0 out of 10

Looks solid

Bullshit score

0.0/10

Comments (0)

No comments yet

Be the first to comment on this document.