A key can hold another store. Build small stores, bind them under keys, and they form a tree.
Build the child first, bind it after. Binding changes nothing about the child, and a nested store costs the same to read and write as a flat one.
See also Core concepts for the vocabulary and the API reference for signatures.
This page builds one example: a workspace with user settings and a set of documents, where each document has its own comments.
workspace leaves: name, syncing ├── settings theme, fontSize (a nested store) └── documents a lookup: one store per document id └── <docId> title, body, dirty, error (contract and error handler) └── comments a lookup: one store per comment id └── <id> author, text, resolved
Build the child first. Settings is a normal store with its own actions. It works on its own and knows nothing about any workspace:
import { createStore, createActions } from '@wildgrowth/voltix'; export const settings = createStore({ theme: 'dark' as 'dark' | 'light', fontSize: 14 }); export const settingsActions = createActions(settings, (store) => ({ toggleTheme: () => store.setKey('theme', store.getKey('theme') === 'dark' ? 'light' : 'dark'), setFontSize: (px: number) => store.setKey('fontSize', px), }));
Then bind it under a key:
export const workspace = createStore({ name: 'Personal', syncing: false, settings, // ← a finished store becomes a child }); workspace.setKey('name', 'Team'); // a parent leaf settingsActions.toggleTheme(); // the child's actions still work workspace.select('settings') === settings; // true: binding keeps the same store
Passing a store as the shape is a TypeError. A store goes under a key: write createStore({ settings }), never createStore(settings).
TypeError
createStore({ settings })
createStore(settings)
What you put under a key decides what the key is:
get()
setKey
This workspace has two leaves and one child:
workspace
workspace.get(); // { name: 'Team', syncing: false }: the workspace's own state workspace.children(); // ['settings']: the settings data lives in `settings`, outside `get()` workspace.setKey('syncing', true); // leaf write workspace.select('settings').setKey('fontSize', 16); // child state is written on the child
workspace.setKey('settings', …) is a compile error: a child key holds no workspace state to write.
workspace.setKey('settings', …)
A plain object never becomes a child. cursor: { line: 1, column: 1 } is one leaf value: replace the whole object to update it, and subscribers to cursor hear one change. When the fields should update independently, use a store instead: cursor: createStore({ line: 1, column: 1 }).
cursor: { line: 1, column: 1 }
cursor
cursor: createStore({ line: 1, column: 1 })
createLookup(factory) makes a lookup: a store per id. at(id) returns the store for that id: the first call runs your factory to create it, and later calls return the same one. Use a lookup for per-item state: documents, rows, entities.
createLookup(factory)
at(id)
The factory builds each store completely, so every document comes with its contract and error handler. Actions take the lookup and a document id:
import { z } from 'zod'; import { createStore, createLookup, createActions } from '@wildgrowth/voltix'; export function makeDocument(id: string) { const document = createStore( { title: 'Untitled', body: '', dirty: false, error: '' }, { schema: { title: z.string().trim().min(1).max(120) } } ); document.onSchemaError((error) => document.setKey('error', error.issues[0].message)); return document; } export const documents = createLookup(makeDocument); export const documentActions = createActions(documents, (lookup) => ({ rename: (id: string, title: string) => lookup.at(id).set({ title, error: '' }), edit: (id: string, body: string) => lookup.at(id).set({ body, dirty: true }), markSaved: (id: string) => lookup.at(id).setKey('dirty', false), }));
Each document checks its own writes and reports its own failures:
documentActions.rename('spec', ' '); // rejected by the contract documents.at('spec').getKey('title'); // 'Untitled': the write was rejected documents.at('spec').getKey('error'); // 'Too small: expected string to have >=1 characters' documentActions.rename('spec', ' Design spec '); documents.at('spec').getKey('title'); // 'Design spec': the contract trimmed it
When each item is plain data with no schema or handler of its own, return a shape from the factory, and the lookup builds the stores. A second argument applies `StoreOptions` to each one:
const presence = createLookup( (userId: string) => ({ online: false, lastSeen: 0 }), { schema: { lastSeen: z.number().int().min(0) } } );
Options apply to shape factories only. A factory that builds its own stores configures them itself, so lookup options are ignored there (with a dev warning).
A lookup has no keys of its own. It has at plus the lifecycle methods:
at
documents.at('readme'); // creates the readme store documents.has('readme'); // true documents.keys(); // ['spec', 'readme']: live ids, in insertion order documents.size(); // 2 documents.remove('spec'); // true; a later at('spec') builds a fresh store documents.clear(); // drop every store
remove and clear drop the store and its subscriptions. They cannot clean up wiring to something external (a subscription on another store, a timer, a socket). Keep those disposers where you can reach them and call one before removing:
remove
clear
const onlineUsers = createStore({ ids: new Set<string>() }); const stopSync = new Map<string, () => void>(); function makePresence(userId: string) { const presence = createStore({ online: false }); stopSync.set(userId, onlineUsers.onChange(['ids'], ({ ids }) => presence.setKey('online', ids.has(userId)))); return presence; } const presences = createLookup(makePresence); function removeUser(userId: string) { stopSync.get(userId)?.(); stopSync.delete(userId); presences.remove(userId); }
In React, a component subscribes to one store from the lookup, so editing one document re-renders one row:
function DocumentRow({ id }: { id: string }) { const { title, dirty } = useStoreSelector(documents.at(id), ['title', 'dirty']); return <li>{title}{dirty && ' •'}</li>; }
A lookup binds under a key exactly like a store. With both pieces built, the workspace takes its full shape from the example:
export const workspace = createStore({ name: 'Personal', syncing: false, settings, // child: a store documents, // child: a lookup });
select
Two methods, one argument each:
select(key)
documentActions.rename('readme', 'README'); workspace.select('documents').at('readme') === documents.at('readme'); // true: same store
Use at when the id comes from data, like documents.at(currentDocId). For a fixed child you usually already have the reference, and settings.setKey('fontSize', 16) does the same thing as workspace.select('settings').setKey('fontSize', 16), so use the shorter one.
documents.at(currentDocId)
settings.setKey('fontSize', 16)
workspace.select('settings').setKey('fontSize', 16)
.children() lists a store's child keys. Selecting a key that is not a child throws a RangeError (and is a compile error first).
.children()
RangeError
Children and lookups mix freely and nest as deep as you need. Each document owns a lookup of comments:
function makeDocument(id: string) { return createStore({ title: 'Untitled', body: '', dirty: false, comments: createLookup((commentId: string) => createStore({ author: '', text: '', resolved: false }) ), // a lookup inside each document }); }
The chain follows the tree: child, id, child, id.
workspace.select('documents').at('readme').select('comments').at('c1').set({ author: 'ada', text: 'Looks good' }); workspace.select('documents').at('readme').select('comments').keys(); // ['c1'] documents.at('readme').select('comments').at('c1').getKey('text'); // 'Looks good'
The tree is only for organizing state. Re-rendering works like it does in a flat store: writing a key re-renders the components reading that key, and nobody else. Take two small components:
function Title({ id }: { id: string }) { const title = useStoreKey(documents.at(id), 'title'); return <h2>{title}</h2>; } function Body({ id }: { id: string }) { const body = useStoreKey(documents.at(id), 'body'); return <article>{body}</article>; }
Now edit one document's body and walk through who re-renders:
documentActions.edit('readme', '# Hello'); // writes `body` on the readme document // <Body id="readme" /> re-renders: it reads that key on that document // <Title id="readme" /> unchanged: same document, different key // <Body id="changelog" /> unchanged: different document // a component reading a workspace key: unchanged, because the write stays inside the child