iTranslated by AI
Nano Stores and the Magic of Cross-Framework State Management
What is nanostores
nanostores is a lightweight (approx 1KB) and framework-agnostic state management library. You can share the same store across React, Vue, Lit, Svelte, Vanilla JS, and more.
npm install nanostores
npm install @nanostores/react # For React
npm install @nanostores/lit # For Lit
Understanding the Basics
atom (Minimum unit of state)
import { atom } from 'nanostores';
const $count = atom(0);
An atom has the following three basic operations:
$count.get(); // Get the current value → 0
$count.set(10); // Update the value
$count.subscribe((value) => { // Monitor value changes
console.log('Changed:', value);
});
subscribe executes a callback every time the value changes. It is mainly used for synchronization with the UI.
About the $ prefix
The $ prefix for variable names is a naming convention recommended by nanostores. It is a convention to make it easier to identify stores in the code and is not mandatory. This article follows the official convention by using the $.
computed (Memoization)
You can create states that are automatically calculated from other atoms.
import { atom, computed } from 'nanostores';
const $todos = atom([
{ id: 1, text: 'Shopping', done: false },
{ id: 2, text: 'Cleaning', done: true },
]);
// Automatically recalculate when $todos changes
const $doneCount = computed($todos, (todos) =>
todos.filter((t) => t.done).length
);
$doneCount.get(); // 1
computed is read-only; you cannot call set directly on it.
persistentAtom (Persistence)
By using @nanostores/persistent, the state is automatically saved to localStorage.
npm install @nanostores/persistent
import { persistentAtom } from '@nanostores/persistent';
// State is restored even after reloading the page
const $todos = persistentAtom('todos', []);
How to Use in Frameworks
About subscribe
In UI frameworks, a re-render is necessary when the state changes. Bindings for each framework wrap subscribe and trigger the re-render for you.
React
import { useStore } from '@nanostores/react';
import { $count } from './stores/counter';
function Counter() {
const count = useStore($count); // Subscribing internally
return <div>{count}</div>;
}
useStore does something like this internally:
// Inside useStore (conceptual)
function useStore(atom) {
const [value, setValue] = useState(atom.get());
useEffect(() => {
const unsubscribe = atom.subscribe((newValue) => {
setValue(newValue); // Re-render when the value changes
});
return unsubscribe;
}, [atom]);
return value; // Return the current value
}
In other words, useStore is simply a wrapper around subscribe.
Lit
import { LitElement, html } from 'lit';
import { StoreController } from '@nanostores/lit';
import { $count } from './stores/counter';
class MyCounter extends LitElement {
private count = new StoreController(this, $count);
render() {
return html`<div>${this.count.value}</div>`;
}
}
Similarly, StoreController subscribes internally and calls requestUpdate().
Vanilla JS / EJS
If you are not using a framework, you update the DOM by subscribing directly.
import { $count } from './stores/counter';
$count.subscribe((value) => {
document.getElementById('counter').textContent = String(value);
});
document.getElementById('increment').addEventListener('click', () => {
$count.set($count.get() + 1);
});
In React/Lit, binding libraries handle the subscribe for you, so you don't need to write it directly. However, in template engine environments like EJS, you need to call subscribe yourself.
Understanding Global Singletons
Same Instance from Anywhere
nanostores state is exported as an ES module, and the same instance is referenced throughout the entire application.
// stores/counter.ts
export const $count = atom(0);
// componentA.ts
import { $count } from './stores/counter';
$count.set(10);
// componentB.ts
import { $count } from './stores/counter';
console.log($count.get()); // 10 (Same instance)
This is the strength of nanostores and the reason why state is shared even across frameworks.
The Problem of Changeability from Anywhere
On the other hand, because there are no restrictions, there is a risk of the state being modified from unexpected locations.
// Anyone can call set
$count.set(999);
$count.set(-1);
Design Policy
Restricting Public APIs
Instead of exporting atoms directly, group them into a store object and mediate operations through functions.
// stores/todo.ts
import { atom, computed } from 'nanostores';
type Todo = { id: number; text: string; done: boolean };
const $todos = atom<Todo[]>([]);
export const todoStore = {
$todos,
$doneCount: computed($todos, (todos) => todos.filter((t) => t.done).length),
add: (text: string) => {
const newTodo = { id: Date.now(), text, done: false };
$todos.set([...$todos.get(), newTodo]);
},
toggle: (id: number) => {
$todos.set(
$todos.get().map((t) => (t.id === id ? { ...t, done: !t.done } : t))
);
},
remove: (id: number) => {
$todos.set($todos.get().filter((t) => t.id !== id));
},
};
The consumer side uses the exposed API.
// ✅ Allowed operations
todoStore.add('New Task');
todoStore.toggle(1);
// ❌ Operations to avoid (but technically possible)
todoStore.$todos.set([]);
However, this is just enforcement by team rules. As long as $todos is exported, it is technically possible to call set directly. You need to handle this through operational measures, such as creating custom ESLint rules or ensuring compliance through code reviews.
Clarifying Domain Boundaries
stores/
├── global/ # State used app-wide
│ └── auth.ts
├── features/ # State scoped to specific features
│ ├── todo.ts
│ └── settings.ts
└── index.ts
Unifying Dependency Direction
global/auth ← features/todo ✅ OK
global/auth ← features/settings ✅ OK
features/todo ← features/settings ❌ NG
If you want to share state between features, promote it to global.
Making it easier to use across frameworks
Challenge: Different syntax for each framework
nanostores is framework-agnostic, but the code on the consumer side differs for each framework.
// React
const todos = useStore(todoStore.$todos);
todoStore.add('Task');
// Lit
private store = new StoreController(this, todoStore.$todos);
// Inside render: this.store.value
// EJS
todoStore.$todos.subscribe((todos) => { ... });
Let's make it possible to handle this with a unified interface.
Solution: Key-based access
Standardize store definition files and provide bindings for each framework. By passing a key, the store for the corresponding domain can be retrieved, and type completion will also work.
Implementation
Store Definition
The store for each domain is defined by splitting it into atoms and actions.
// stores/todo.ts
import { atom, computed } from 'nanostores';
import { persistentAtom } from '@nanostores/persistent';
type Todo = { id: number; text: string; done: boolean };
const $todos = persistentAtom<Todo[]>('todos', []);
const $doneCount = computed($todos, (todos) => todos.filter((t) => t.done).length);
const actions = {
add: (text: string) => {
const newTodo = { id: Date.now(), text, done: false };
$todos.set([...$todos.get(), newTodo]);
},
toggle: (id: number) => {
$todos.set(
$todos.get().map((t) => (t.id === id ? { ...t, done: !t.done } : t))
);
},
remove: (id: number) => {
$todos.set($todos.get().filter((t) => t.id !== id));
},
clear: () => {
$todos.set([]);
},
};
export const todoStore = {
atoms: { $todos, $doneCount },
actions,
};
Registry
Register the stores as a list to serve as the basis for type definitions.
// stores/registry.ts
import { todoStore } from './todo';
export const stores = {
todo: todoStore,
} as const;
export type StoreKey = keyof typeof stores;
export type StoreDefinition<K extends StoreKey> = (typeof stores)[K];
React Binding
// stores/useStore.ts
import { useStore as useNanoStore } from '@nanostores/react';
import { stores, StoreKey, StoreDefinition } from './registry';
type AtomValue<A> = A extends { get: () => infer V } ? V : never;
type UseStoreResult<K extends StoreKey> = {
[P in keyof StoreDefinition<K>['atoms']]: AtomValue<StoreDefinition<K>['atoms'][P]>;
} & StoreDefinition<K>['actions'];
export const useStore = <K extends StoreKey>(key: K): UseStoreResult<K> => {
const { atoms, actions } = stores[key];
const result: Record<string, unknown> = {};
for (const [name, atom] of Object.entries(atoms)) {
result[name] = useNanoStore(atom);
}
return { ...result, ...actions } as UseStoreResult<K>;
};
Lit Binding
// stores/createStoreController.ts
import { ReactiveControllerHost } from 'lit';
import { StoreController } from '@nanostores/lit';
import { stores, StoreKey, StoreDefinition } from './registry';
type AtomController<A> = A extends { get: () => infer V }
? StoreController<V>
: never;
type ControllerResult<K extends StoreKey> = {
[P in keyof StoreDefinition<K>['atoms']]: AtomController<
StoreDefinition<K>['atoms'][P]
>;
} & StoreDefinition<K>['actions'];
export const createStoreController = <K extends StoreKey>(
host: ReactiveControllerHost,
key: K
): ControllerResult<K> => {
const { atoms, actions } = stores[key];
const result: Record<string, unknown> = {};
for (const [name, atom] of Object.entries(atoms)) {
result[name] = new StoreController(host, atom);
}
return { ...result, ...actions } as ControllerResult<K>;
};
EJS / Vanilla JS Binding
// stores/bindStore.ts
import { stores, StoreKey, StoreDefinition } from './registry';
type AtomValue<A> = A extends { get: () => infer V } ? V : never;
type BoundStoreResult<K extends StoreKey> = {
subscribe: (
callback: (values: {
[P in keyof StoreDefinition<K>['atoms']]: AtomValue<StoreDefinition<K>['atoms'][P]>;
}) => void
) => () => void;
} & StoreDefinition<K>['actions'];
export const bindStore = <K extends StoreKey>(key: K): BoundStoreResult<K> => {
const { atoms, actions } = stores[key];
const getValues = () => {
const values: Record<string, unknown> = {};
for (const [name, atom] of Object.entries(atoms)) {
values[name] = (atom as { get: () => unknown }).get();
}
return values;
};
const subscribe = (callback: (values: Record<string, unknown>) => void) => {
const unsubscribes: (() => void)[] = [];
for (const atom of Object.values(atoms)) {
const unsubscribe = (
atom as { subscribe: (fn: () => void) => () => void }
).subscribe(() => {
callback(getValues());
});
unsubscribes.push(unsubscribe);
}
callback(getValues()); // Initial execution
return () => unsubscribes.forEach((fn) => fn());
};
return { subscribe, ...actions } as BoundStoreResult<K>;
};
Directory Structure
stores/
├── registry.ts # Store list + type definitions
├── useStore.ts # For React
├── createStoreController.ts # For Lit
├── bindStore.ts # For EJS / Vanilla JS
└── todo.ts # Domain store
Usage Examples
React[1]
import { useStore } from '@/stores/useStore';
function TodoApp() {
const { $todos, $doneCount, add, toggle, remove, clear } = useStore('todo');
const [text, setText] = useState('');
const handleSubmit = (e: FormEvent) => {
e.preventDefault();
if (text.trim()) {
add(text);
setText('');
}
};
return (
<div>
<form onSubmit={handleSubmit}>
<input value={text} onChange={(e) => setText(e.target.value)} />
<button type="submit">Add</button>
</form>
<ul>
{$todos.map((todo) => (
<li key={todo.id}>
<input
type="checkbox"
checked={todo.done}
onChange={() => toggle(todo.id)}
/>
<span>{todo.text}</span>
<button onClick={() => remove(todo.id)}>Delete</button>
</li>
))}
</ul>
<p>Completed: {$doneCount}</p>
<button onClick={clear}>Clear All</button>
</div>
);
}
Lit
import { LitElement, html } from 'lit';
import { customElement, state } from 'lit/decorators.js';
import { createStoreController } from '@/stores/createStoreController';
@customElement('todo-app')
export class TodoApp extends LitElement {
private store = createStoreController(this, 'todo');
@state() private text = '';
private handleSubmit(e: Event) {
e.preventDefault();
if (this.text.trim()) {
this.store.add(this.text);
this.text = '';
}
}
render() {
const todos = this.store.$todos.value;
const doneCount = this.store.$doneCount.value;
return html`
<form @submit=${this.handleSubmit}>
<input
.value=${this.text}
@input=${(e: Event) => (this.text = (e.target as HTMLInputElement).value)}
/>
<button type="submit">Add</button>
</form>
<ul>
${todos.map(
(todo) => html`
<li>
<input
type="checkbox"
?checked=${todo.done}
@change=${() => this.store.toggle(todo.id)}
/>
<span>${todo.text}</span>
<button @click=${() => this.store.remove(todo.id)}>Delete</button>
</li>
`
)}
</ul>
<p>Completed: ${doneCount}</p>
<button @click=${this.store.clear}>Clear All</button>
`;
}
}
EJS / Vanilla JS
<!-- todo.ejs -->
<form id="todo-form">
<input type="text" id="todo-input" />
<button type="submit">Add</button>
</form>
<ul id="todo-list"></ul>
<p>Completed: <span id="done-count"></span></p>
<button id="clear-btn">Clear All</button>
<script type="module">
import { bindStore } from '@/stores/bindStore.js';
const store = bindStore('todo');
store.subscribe(({ $todos, $doneCount }) => {
document.getElementById('todo-list').innerHTML = $todos
.map(
(todo) => `
<li>
<input type="checkbox" data-id="${todo.id}" ${todo.done ? 'checked' : ''} />
<span>${todo.text}</span>
<button data-remove="${todo.id}">Delete</button>
</li>
`
)
.join('');
document.getElementById('done-count').textContent = $doneCount;
});
document.getElementById('todo-form').addEventListener('submit', (e) => {
e.preventDefault();
const input = document.getElementById('todo-input');
if (input.value.trim()) {
store.add(input.value);
input.value = '';
}
});
document.getElementById('todo-list').addEventListener('change', (e) => {
if (e.target.type === 'checkbox') {
store.toggle(Number(e.target.dataset.id));
}
});
document.getElementById('todo-list').addEventListener('click', (e) => {
if (e.target.dataset.remove) {
store.remove(Number(e.target.dataset.remove));
}
});
document.getElementById('clear-btn').addEventListener('click', () => {
store.clear();
});
</script>
Summary of Structure
useStore('todo') ─────────────────┐
│
createStoreController(this, 'todo') ──→ Referencing the same atoms
│
bindStore('todo') ────────────────┘
- Store definition in one place (atoms + actions)
- Absorb differences with framework-specific bindings
- Type completion works with keys
- Since they reference the same atoms, state is synchronized across frameworks
Conclusion
| Concept | Description |
|---|---|
| atom | The smallest unit of state. get / set / subscribe |
| computed | Derived state. Automatically calculated from other atoms |
| persistentAtom | Atoms that are persisted in localStorage |
| useStore / StoreController | Wraps subscribe to synchronize with the UI |
| Global Singleton | The same instance from the same key |
nanostores is a lightweight, framework-agnostic state management library, but due to its nature as a global singleton, design is important.
By using the key-based design pattern introduced in this article, you gain the following benefits:
- Store definitions can be centralized in one place
- Can be used with the same interface across frameworks
- Safe to use because type completion works
- State is synchronized even in environments where React/Lit/EJS coexist
Why did I write this article?
-
I like React, so I included it as one pattern. ↩︎
Discussion