Skip to content

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?

FeatureZustandcallbag-recharge createStore
Computed/derivedNone built-inselect() — automatic, diamond-safe
MemoizationManual useShallowPush-phase, automatic via equals
Async actionsNativeNative
Framework lock-inReact hooksNone — framework-agnostic
InspectableDevTools extensionInspector.dumpGraph() — runtime graph
Composable with streamsNoFull 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.is memoization by default
  • Is a full callbag-recharge Store — composable with derived, 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 callback

subscribeWithSelector → 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.

Released under the MIT License.