A task board, built step by step: one store first, then actions, contracts, a store per task, and server loading. Each step adds one idea and the code keeps working between steps.
A store is a flat object of keys, created once at module scope. No provider, no context.
// ui.ts import { createStore } from '@wildgrowth/voltix'; export type Filter = 'all' | 'open' | 'done'; export const ui = createStore({ filter: 'all' as Filter, search: '', });
Read one key with useStoreKey. It returns the value itself.
useStoreKey
import { useStoreKey } from '@wildgrowth/voltix'; import { ui } from './ui'; function SearchBox() { const search = useStoreKey(ui, 'search'); return <input value={search} onChange={(event) => ui.setKey('search', event.target.value)} />; }
useStoreSelector takes an array of keys and returns just those keys.
useStoreSelector
function Toolbar() { const { filter, search } = useStoreSelector(ui, ['filter', 'search']); return <span>{filter} · {search}</span>; }
Subscriptions are per key: writing filter never wakes a component that only reads search.
filter
search
ui.subscribe(['filter'], () => console.log('filter')); ui.subscribe(['search'], () => console.log('search')); ui.setKey('filter', 'open'); // logs 'filter' ui.setKey('search', 'milk'); // logs 'search' ui.setKey('filter', 'open'); // logs nothing — same value, no write
createActions collects a store's writes in one place and returns them as a plain object. The store itself is untouched.
createActions
// ui.ts import { createStore, createActions } from '@wildgrowth/voltix'; export const ui = createStore({ filter: 'all' as Filter, search: '' }); export const uiActions = createActions(ui, (store) => ({ setFilter: (filter: Filter) => store.setKey('filter', filter), setSearch: (query: string) => store.setKey('search', query), clear: () => store.reset(), })); uiActions.setFilter('open'); ui.setKey('search', 'milk'); // the store is still just a store
Actions are plain functions in a plain object: import them where needed, and combine sets with object spread.
A task has rules (the title), behaviour, and a place to record a rejected write. Build it as a complete store before it joins any collection.
// task.ts import { z } from 'zod'; import { createStore } from '@wildgrowth/voltix'; export function makeTask(id: string) { const task = createStore( { title: '', done: false, error: '' }, { schema: { title: z.string().trim().min(1).max(80) } } // its contract ); task.onSchemaError((error) => task.setKey('error', error.issues[0].message)); // its failure policy return task; }
Every task now behaves like this, wherever it ends up:
const task = makeTask('t1'); task.set({ title: ' ', error: '' }); // rejected by the contract task.getKey('title'); // '' — the write never landed task.getKey('error'); // 'Too small: expected string to have >=1 characters' task.set({ title: ' Buy milk ', error: '' }); task.getKey('title'); // 'Buy milk' — the schema trimmed it on the way in task.getKey('error'); // '' — cleared by the same write
The contract runs on every write, and the error handler runs after the write settles. That is why set({ title, error: '' }) can clear an old error without erasing the message a rejection just wrote.
set({ title, error: '' })
createLookup makes a lookup: a store per id. at(id) creates the store on first use and returns the same one after. The factory returns a finished store, so every task comes with its contract and error handling attached. Task actions go over the lookup, id first:
createLookup
at(id)
// tasks.ts import { createLookup, createActions } from '@wildgrowth/voltix'; import { makeTask } from './task'; export const tasks = createLookup(makeTask); export const taskActions = createActions(tasks, (lookup) => ({ rename: (id: string, title: string) => lookup.at(id).set({ title, error: '' }), toggle: (id: string) => lookup.at(id).toggle('done'), })); taskActions.rename('t1', 'Buy milk'); taskActions.toggle('t1'); tasks.at('t1') === tasks.at('t1'); // true — memoized, the exact node makeTask built
at(id) is how you reach the store for an id. has, keys, size, remove, and clear handle the lifecycle.
has
keys
size
remove
clear
Bind the finished stores under one root. A key holding a store is a child; the other keys stay plain data.
// app.ts import { createStore, createActions } from '@wildgrowth/voltix'; import { ui } from './ui'; import { tasks, taskActions } from './tasks'; export const app = createStore({ ids: new Set<string>(), // membership is the semantic — a Set can't hold a duplicate row count: 0, open: 0, loading: false, ui, // child store tasks, // child lookup }); export const appActions = createActions(app, (store) => ({ add(id: string, title: string) { if (store.getKey('ids').has(id)) return; // idempotent — a double add is a no-op taskActions.rename(id, title); store.update('ids', (ids) => new Set(ids).add(id)); // fresh Set, so subscribers notify store.increment('open'); }, remove(id: string) { if (!store.getKey('ids').has(id)) return; if (!store.select('tasks').at(id).getKey('done')) store.increment('open', -1); store.update('ids', (ids) => { const next = new Set(ids); next.delete(id); return next; }); store.select('tasks').remove(id); }, toggle(id: string) { taskActions.toggle(id); store.increment('open', store.select('tasks').at(id).getKey('done') ? -1 : 1); }, }));
ids is a Set: duplicates are impossible and has is O(1). The guards make add and remove safe to call twice, so a double-click can't duplicate a row or corrupt the counters. Writes always build a fresh Set, because Voltix compares by reference and mutating the current one changes data without notifying anyone.
ids
Set
add
Children stay out of the parent's state, and binding changes nothing about them:
app.get(); // { ids: Set(0), count: 0, open: 0, loading: false } app.children(); // ['ui', 'tasks'] app.select('ui') === ui; // true app.select('tasks').at('t1') === tasks.at('t1'); // true
Both ways of reaching a child are the same call; use whichever reads better. ui.setKey('filter', 'open') does the same as app.select('ui').setKey('filter', 'open'). at is for dynamic ids, like app.select('tasks').at(someId). Note that appActions calls taskActions directly: actions are plain functions, so app behaviour is built out of task behaviour with ordinary calls.
ui.setKey('filter', 'open')
app.select('ui').setKey('filter', 'open')
at
app.select('tasks').at(someId)
appActions
taskActions
derive computes a key from other keys in the same store, inside the write that changes an input:
derive
app.derive('count', ['ids'], ({ ids }) => ids.size); appActions.add('t1', 'Buy milk'); app.getKey('count'); // 1, already committed
open depends on state spread across the lookup's stores, which derive cannot see. Update it from the actions that change it (add, remove, toggle above): O(1) per change instead of re-scanning every task.
open
toggle
Each component subscribes to exactly what it reads.
import { useStoreKey, useStoreSelector } from '@wildgrowth/voltix'; import { app, appActions } from './app'; import { tasks } from './tasks'; function Header() { const { count, open } = useStoreSelector(app, ['count', 'open']); return <h1>{open} open / {count} total</h1>; } function Row({ id }: { id: string }) { const { title, done, error } = useStoreSelector(tasks.at(id), ['title', 'done', 'error']); return ( <li> <input type="checkbox" checked={done} onChange={() => appActions.toggle(id)} /> {title} {error && <em>{error}</em>} <button onClick={() => appActions.remove(id)}>×</button> </li> ); } function Board() { const ids = useStoreKey(app, 'ids'); return ( <> <Header /> <ul>{[...ids].map((id) => <Row key={id} id={id} />)}</ul> </> ); }
Toggling one task re-renders that `Row` only. Header re-renders when count or open actually changed. Adding a task changes ids and count, so the list and Header update.
Header
count
Async work goes in an action:
export async function loadBoard() { app.setKey('loading', true); try { const res = await fetch('/api/tasks'); const rows: { id: string; title: string; done: boolean }[] = await res.json(); tasks.clear(); for (const r of rows) tasks.at(r.id).set({ title: r.title, done: r.done }); app.set({ ids: new Set(rows.map((r) => r.id)), open: rows.filter((r) => !r.done).length, loading: false, }); } catch { app.setKey('loading', false); } }
Server data is written straight into the task stores with set. It still passes each task's contract, so a malformed row is rejected per key instead of landing on the board.
set
Call it once where the data is first needed:
useEffect(() => { loadBoard(); }, []);
Persist preferences; server data reloads from the server. Seed the initial state from storage, then attach persist.
persist
import { createStore, persist, loadPersistedState } from '@wildgrowth/voltix'; type UiState = { filter: Filter; search: string }; const persisted = loadPersistedState<UiState>(localStorage, 'board', ['filter']); export const ui = createStore<UiState>( { filter: 'all', search: '', ...persisted }, { schema: { filter: z.enum(['all', 'open', 'done']).catch('all') } } ); persist<UiState>(ui, localStorage, 'board', ['filter']);
.catch('all') handles junk in storage (users edit it, old deploys leave old shapes behind): a bad value becomes 'all' at construction instead of an impossible filter in your UI.
.catch('all')
'all'
app leaves: ids, count, open, loading ├── ui filter, search (+ actions, persisted, contract) └── tasks lookup └── <id> title, done, error (+ actions, contract, error handler)
Reading back what each piece did:
Next: Patterns for polling and live tables, Composition for deeper trees, API reference for the full surface.