createStore: Zustand-Compatible API
A Zustand-style single-store pattern backed by callbag-recharge's reactive graph. Familiar API, but with diamond-safe derived selectors, automatic memoization, and full composability.
Quick Example
ts
/**
* createStore — Zustand-compatible API with diamond-safe selectors
*
* Shows how createStore gives you Zustand's familiar API backed by
* callbag-recharge's reactive graph. Selectors are derived stores
* with automatic push-phase memoization.
*
* Run: npx tsx examples/create-store.ts
*/
import { effect } from "callbag-recharge";
import { subscribe } from "callbag-recharge/extra";
import { batch, createStore } from "callbag-recharge/patterns/createStore";
// ── Create a store ─────────────────────────────────────────
interface Todo {
text: string;
done: boolean;
}
const store = createStore((set, _get) => ({
// State
count: 0,
todos: [] as Todo[],
// Actions — just functions that call set()
increment: () => set((s) => ({ count: s.count + 1 })),
decrement: () => set((s) => ({ count: s.count - 1 })),
addTodo: (text: string) => set((s) => ({ todos: [...s.todos, { text, done: false }] })),
toggleTodo: (i: number) =>
set((s) => ({
todos: s.todos.map((t, idx) => (idx === i ? { ...t, done: !t.done } : t)),
})),
}));
// ── Selectors — the killer feature ─────────────────────────
// Each select() returns a reactive Store backed by derived()
const count = store.select((s) => s.count);
const todoCount = store.select((s) => s.todos.length);
const doneCount = store.select((s) => s.todos.filter((t) => t.done).length);
// ── React to changes ───────────────────────────────────────
effect([count], () => {
console.log("Count:", count.get());
});
const unsub = subscribe(todoCount, (n) => {
console.log("Todos:", n, "| Done:", doneCount.get());
});
// ── Use it ─────────────────────────────────────────────────
store.getState().increment(); // → Count: 1
store.getState().increment(); // → Count: 2
store.getState().addTodo("Learn callbag-recharge"); // → Todos: 1 | Done: 0
store.getState().addTodo("Build something cool"); // → Todos: 2 | Done: 0
// toggleTodo changes doneCount but NOT todoCount → subscribe doesn't fire
// This is push-phase memoization: todoCount selector stays at 2, no recompute
store.getState().toggleTodo(0); // (silent — todoCount unchanged)
// Batching — multiple updates, single notification
batch(() => {
store.getState().increment();
store.getState().addTodo("Ship it");
});
// → Count: 3 (fires once, not twice)
// → Todos: 3 | Done: 1 (fires once)
// Cleanup
unsub();
store.destroy();
console.log("--- done ---");Why createStore?
| Feature | Zustand | callbag-recharge createStore |
|---|---|---|
| Computed/derived | None built-in | select() — automatic, diamond-safe |
| Memoization | Manual useShallow | Push-phase, automatic via equals |
| Async actions | Native | Native |
| Framework lock-in | React hooks | None — framework-agnostic |
| Inspectable | DevTools extension | Inspector.dumpGraph() — runtime graph |
| Composable with streams | No | Full callbag-recharge interop |
Selectors — The Killer Feature
Zustand has no built-in computed values. createStore does.
select() returns a reactive Store<U> that:
- Recomputes only when dependencies change (push-based, not poll-based)
- Is diamond-safe — no glitches in complex dependency graphs
- Uses
Object.ismemoization by default - Is a full callbag-recharge
Store— composable withderived,effect,pipe
ts
const count = store.select(s => s.count)
const completedCount = store.select(s => s.todos.filter(t => t.done).length)
// Custom equality for array/object selectors
const todoTexts = store.select(
s => s.todos.map(t => t.text),
(a, b) => a.length === b.length && a.every((v, i) => v === b[i])
)Migration from Zustand
diff
- import { create } from 'zustand'
+ import { createStore } from 'callbag-recharge/patterns/createStore'
- const useStore = create((set) => ({
+ const store = createStore((set) => ({
count: 0,
increment: () => set((s) => ({ count: s.count + 1 })),
}))
// Reading state
- const count = useStore((s) => s.count)
+ const count = store.select((s) => s.count)
+ count.get()
// Subscribing
- useStore.subscribe((state) => console.log(state))
+ store.subscribe((state, prev) => console.log(state))Replacing Zustand Middleware
persist → effect() (2 lines)
ts
import { effect } from 'callbag-recharge'
effect([store.store], () => {
localStorage.setItem('my-store', JSON.stringify(store.getState()))
})devtools → Inspector (built-in)
ts
import { Inspector } from 'callbag-recharge'
Inspector.dumpGraph() // entire reactive graph
Inspector.trace(store.store, console.log) // value change callbacksubscribeWithSelector → select() (already built-in)
ts
const count = store.select(s => s.count)
subscribe(count, (value) => console.log('count:', value))Full API Reference
See the createStore README for the complete API, including setState, getInitialState, destroy, and composition with callbag-recharge primitives.