Augmenting nanostores: designing a layer for better DX
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
nanostoreswith better DX — without losing a single gram of the engine's reactivity. Step by step, we'll build enhancedatom,map, andcomputedwith 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:
- A scattered import point:
atom/map/computedfrom one package,useStorefrom another. The developer has to remember both. - A hook detached from the store:
useStore($count)— you have to import the hook and hand it the store. It's begging for$countto "know" how to render itself. - 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 thenanostoresAPI and our additions at once. satisfiesguards the contract. We verify the object with the methods againstEnhancedAtom<T>, but it reachesObject.assignwithout 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 ofuseStore($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 keepingINITIALby hand. The initial value is closed over in the factory's closure, soreset()simply works.getInitial()— access to the starting value. Useful, for example, to detect "was the form modified?" (current !== getInitial()).- The
TStoreExtgeneric — it's just a pass-through fornanoAtom's second parameter, so that anynanostoresstore 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. inonClick), 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 rawnanostores, "removing" a key is done viasetKey(key, undefined). That's tribal knowledge. We give the intent a name:removeKey('coupon')reads exactly the way it behaves.
Under the hood
removeKeyissetKey(key, undefined), and innanostoresthat 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: afterremoveKeyit 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 thesetKeytype 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
removeKeycallssetKey(key, undefined). In thenanostoresimplementation this doesn't end withundefinedbeing stored — the library performs adeleteon the object. That means the key really disappears from the map, and observers are notified exactly as on a regular change.
key as any— ourTKey extends keyof TValueand the library'sKey extends AllKeys<Value>are two open generics. Inside the method body TS can't prove thatkeyof TValueis assignable toAllKeys<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 signatureremoveKey<TKey extends keyof TValue>(key: TKey)stays 100% typed. Callers never touchany— 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.
computeddoesn't compute anything until someone starts listening to it (subscribe/listen/use()) or until you read it viaget().
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
nanostoresengine (reference comparison, laziness, synchronous propagation) — it simply inherits it.
Cheat sheet
DX friction (raw nanostores) | Solution | Technique |
|---|---|---|
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 styles | import { 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.
nanostoresadvertises numbers on the order of ~265 bytes per store — that's its trademark. Addingreset,getInitial, andremoveKeyto 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(), becauseuse()is a React hook, andnanostoresalso 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.
nanostoresdeliberately provides primitives (atom,map,computed), not ready-made application patterns.resetor "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
setKeytype is a feature, not a bug. The rigor that forces us intoas anyinsideremoveKeyprotects 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.
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
Looks solid
Bullshit score
0.0/10
Comments (0)
No comments yet
Be the first to comment on this document.