API reference
Every export from @zaatar-tech/voltix, plus every method on a store. Types are shown as they appear in the source. T is your state shape.
Contents:
createStoreandStoreOptionscreateLookupandLookup—at·has·remove·keys·size·clearStore—select·childrencreateActions- Schema contracts — initial state ·
onSchemaError - Store methods:
get·getKey·set·setKey·update·increment·toggle·mergeSet·reset·batch·subscribe·pick·intercept·onChange·derive·onSchemaError useStoreKeyuseStoreSelectorcreateStoreHook- Helpers:
merge·persist·loadPersistedState - Storage interfaces
- ESLint plugin
createStore
function createStore<T extends object>(
shape: T,
options?: StoreOptions<LeafState<T>>
): Store<T>;
Creates a Store from a state object. A key holding a store or Lookup becomes a child (see Composition); every other key is a leaf. Passing a store as the shape, or passing a function, is a TypeError: stores go under a key, factories go to createLookup.
import { createStore } from '@zaatar-tech/voltix';
const store = createStore({ count: 0, name: 'Ada' });
Call it once at module scope and share the reference.
StoreOptions
interface StoreOptions<T extends object> {
equals?: { [K in keyof T]?: (prev: T[K], next: T[K]) => boolean };
schema?: { [K in keyof T]?: StandardSchemaV1<any, T[K]> };
}
equals registers a per-key equality function, fixed at creation. On a write, the function runs with (prev, next); returning true treats the values as equal and skips the write. For a lookup, options apply to every store it builds.
const store = createStore(
{ user: { id: 1, name: 'Ada' } },
{ equals: { user: (prev, next) => prev.id === next.id } }
);
schema registers a per-key write contract — see Schema contracts.
LeafState<T> is T with its child-store keys removed, so options and every store method see only the reactive leaves.
createLookup
function createLookup<Id extends PropertyKey, N extends NodeBrand>(
factory: (id: Id) => N
): Lookup<Id, N>;
function createLookup<Id extends PropertyKey, C extends object>(
factory: (id: Id) => C,
options?: StoreOptions<LeafState<C>>
): Lookup<Id, Store<C>>;
Creates a Lookup: a store per id. The first at(id) runs factory and keeps the result. A factory that returns a built store is kept exactly as returned; a factory that returns a plain shape gets a store built for it, with options applied to each one.
import { createLookup } from '@zaatar-tech/voltix';
const documents = createLookup((id: string) => ({ title: 'Untitled', body: '' }));
documents.at('readme').setKey('title', 'README');
Lookup
interface Lookup<Id extends PropertyKey, S extends object> {
at(id: Id): S;
has(id: Id): boolean;
remove(id: Id): boolean;
keys(): Id[];
size(): number;
clear(): void;
}
S is the store type: exactly what the factory returns when it builds stores itself, or Store<C> when it returns a shape. Each store in the lookup has its own per-key subscriptions, so updating one item re-renders only that item. See lookups.
at(id) returns the store at id, created on first access and reused after. It takes one id; to walk into that store's own children, continue with select: documents.at('readme').select('comments').at('c1').
documents.at('readme').setKey('title', 'README');
function DocumentRow({ id }: { id: string }) {
const title = useStoreKey(documents.at(id), 'title'); // only this row re-renders
return <li>{title}</li>;
}
has(id) reports whether a store exists for id; remove(id) drops one (a later at starts fresh); keys() lists live ids in insertion order; size() counts them; clear() drops all.
documents.has('readme'); // true
documents.keys(); // ['readme']
documents.size(); // 1
documents.remove('readme'); // true
documents.clear();
When a store needs behaviour, the factory builds it complete:
const todos = createLookup((id: string) => {
const todo = createStore(
{ text: `todo ${id}`, done: false, error: '' },
{ schema: { text: z.string().min(1) } }
);
todo.onSchemaError((error) => todo.setKey('error', error.issues[0].message));
return todo;
});
todos.at('t1'); // the store is exactly what the factory built — contract and handler included
For plain per-id data, a shape factory plus a second argument of StoreOptions applies equals and schema to every store the lookup builds:
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 returns a built store configures it itself, and lookup options are ignored for it (with a dev warning).
Store
interface Store<T extends object> extends StoreType<LeafState<T>> {
select<K extends ChildKey<T>>(key: K): T[K];
children(): ChildKey<T>[];
}
The value returned by createStore(shape). It carries every store method below (typed to the leaf keys), plus navigation for its child nodes.
Store.select
select(key): childNode
One structural step: returns the child node (a nested Store or Lookup) bound at key. Takes exactly one key, always a child key, and chains for depth. Navigating to a key that is not a child throws a RangeError (and is a compile error first).
const workspace = createStore({
name: 'Personal',
settings: createStore({ theme: 'dark', fontSize: 14 }),
documents: createLookup((id: string) => ({ title: 'Untitled', body: '' })),
});
workspace.select('settings').setKey('fontSize', 16);
workspace.select('documents').at('readme').setKey('title', 'README');
Store.children
children(): ChildKey<T>[]
The names of this store's child keys (the keys holding a nested store or lookup).
workspace.children(); // ['settings', 'documents']
createActions
function createActions<S extends object, A extends object>(
store: S,
define: (store: S) => A
): A
Defines actions for a store (or lookup) and returns them as a standalone object. The store is untouched. define receives the node fully typed, and actions can reach across a composed tree with select(key) / at(id).
const counter = createStore({ count: 0 });
const counterActions = createActions(counter, (store) => ({
increment: () => store.increment('count'),
add: (amount: number) => store.increment('count', amount),
reset: () => store.reset(['count']),
}));
counterActions.increment();
counter.setKey('count', 5); // the store is still just a store
Actions over a lookup take the id first:
const todos = createLookup((id: string) => ({ text: '', done: false }));
const todoActions = createActions(todos, (lookup) => ({
add: (id: string, text: string) => lookup.at(id).set({ text }),
toggle: (id: string) => lookup.at(id).toggle('done'),
}));
todoActions.add('t1', 'Ship it');
Actions are plain functions in a plain object: export them beside the store, combine sets with object spread ({ ...baseActions, ...adminActions }), and call one from another like any function.
Schema contracts
options.schema puts a per-key write contract on a store. A write runs the key's schema first: if it reports issues the write for that key is vetoed (the stored value is untouched and no subscriber is notified), and if the schema transforms, the parsed output is what gets stored.
Schemas are Standard Schemas, the shared interface implemented by zod (≥3.24), valibot (≥1.0), and arktype (≥2.0). Voltix vendors only the types, so bring whichever library you like and Voltix stays dependency-free.
import { z } from 'zod';
import { createStore } from '@zaatar-tech/voltix';
const account = createStore(
{ balance: 0, email: '' },
{
schema: {
balance: z.number().min(0),
email: z.string().trim().toLowerCase(),
},
}
);
account.setKey('balance', -20); // vetoed; balance stays 0
account.setKey('email', ' A@B.CO '); // stored as 'a@b.co'
A schema's output type must match the key's type, so a mismatch is a compile error:
createStore({ count: 0 }, { schema: { count: z.string() } }); // ✗ output is string, key is number
Rules worth knowing:
- Every write path is covered.
set,setKey,update,increment,toggle,mergeSet, andresetall validate. Inside a multi-keyset, a vetoed key is dropped while the rest of the update commits. - Initial state is validated too. See below.
- Order is schema → equality → notify. Equality (
options.equals) compares the parsed value, so a write that transforms into the current value is correctly skipped. - Validation must be synchronous. An async schema is vetoed with an explanatory error; validate async input before writing it.
- Rejections go to
console.errorunless a handler is registered withonSchemaError. In development the message names the key and vendor and lists each issue with its path; production builds log a terse line. A schema that throws vetoes the write. - Cost. A store with no
schemaskips validation entirely. Once a store has one, a validated write costs roughly 30–75ns depending on the schema, and writes to its other keys carry a small fixed overhead (~11ns). Put contracts on boundary keys (server payloads, forms, storage) rather than on keys written every animation frame.
For a lookup, schemas passed in the second argument apply to every store it builds.
const presence = createStore(
(userId: string) => ({ online: false, lastSeen: 0 }),
{ schema: { lastSeen: z.number().int().min(0) } }
);
Schemas are locked at creation; there is no runtime add or remove.
Initial state
Starting values are checked at construction, so a bad one is caught immediately. Transforms apply to them too, and reset() restores the transformed value.
const store = createStore(
{ email: ' ADA@Example.COM ' },
{ schema: { email: z.string().trim().toLowerCase() } }
);
store.getKey('email'); // 'ada@example.com' — normalized before the first write
A bad starting value is logged to the console and kept as-is; construction never throws (a store seeded from corrupt localStorage should not crash the app at import). Handlers don't exist yet at construction, so these always go to the console. Stores built by a lookup are checked the same way as each is created.
onSchemaError
onSchemaError(handler: (error: SchemaError<T>) => void): () => void
interface SchemaError<T> {
key: keyof T; // the key whose write was vetoed
issues: ReadonlyArray<StandardSchemaIssue>;
value: unknown; // the rejected value
store: StoreType<T>; // the node that rejected it
}
Registers a handler for rejected writes and returns a disposer. It registers after construction, so it can use the finished store and any createActions defined beside it.
const account = createStore(
{ email: 'ada@lovelace.dev', emailError: '' },
{ schema: { email: z.string().email() } }
);
const accountActions = createActions(account, (store) => ({
flagEmail: (message: string) => store.setKey('emailError', message),
}));
account.onSchemaError((error) => accountActions.flagEmail(error.issues[0].message));
account.setKey('email', 'not-an-email');
account.getKey('email'); // 'ada@lovelace.dev' — still vetoed
account.getKey('emailError'); // 'Invalid email address'
While any handler is registered the console logging is off; disposing the last one turns it back on. The write stays vetoed either way. Handlers run synchronously, so keep them cheap; one that throws is logged and the rest still run. A handler that itself writes a rejected value does not re-enter; that second rejection goes to the console.
Registration is per store. In a lookup, register the handler inside the factory so every store gets one:
const todos = createLookup((id: string) => {
const todo = createStore({ text: '', error: '' }, { schema: { text: z.string().min(1) } });
todo.onSchemaError((error) => todo.setKey('error', error.issues[0].message));
return todo;
});
Store methods
A Store implements StoreType<LeafState<T>>. Every method operates on the reactive leaf keys.
get
get(): T
Returns the whole leaf state object. The reference is stable across writes (see identity-stable state). Child stores are not included.
const { count } = store.get();
getKey
getKey<K extends keyof T>(key: K): T[K]
Returns one key's current value.
const count = store.getKey('count');
set
set(update: Partial<T>): void
Writes several keys in one update. Only the keys whose value actually changed notify.
store.set({ count: 5, name: 'Bob' });
Only the keys you pass are written. A key absent from the partial is left alone, so spreading a patch cannot blank out state it doesn't mention. A key you do pass is written, including when its value is undefined.
store.set({ name: 'Bob' }); // count untouched, name written
store.set({ count: undefined }); // count written as undefined, and notifies
store.set({ ...maybeEmpty }); // absent keys stay absent
setKey
setKey<K extends keyof T>(key: K, value: T[K]): void
Sets a single key. Equivalent to set({ [key]: value }) for one key, on a faster path. undefined is a value like any other here: it commits and notifies.
store.setKey('count', 5);
update
update<K extends keyof T>(key: K, updater: (prev: T[K]) => T[K]): void
Sets a key from its current value.
store.update('count', (c) => c + 1);
store.update('tags', (tags) => [...tags, 'new']);
increment
increment(key: NumericKeys<T>, amount?: number): void
Adds amount (default 1) to a numeric key. The type system allows this only on keys whose value is a number. Pass a negative amount to subtract.
store.increment('count'); // + 1
store.increment('count', 10); // + 10
store.increment('count', -1); // - 1
toggle
toggle(key: BooleanKeys<T>): void
Flips a boolean key. Allowed only on keys whose value is a boolean.
store.toggle('isOpen');
mergeSet
mergeSet<K extends keyof T>(key: K, value: T[K] extends object ? Partial<T[K]> : never): void
Merges a partial into an object key and writes the result. The key gets a new object reference, so its subscribers are notified. For a pure computed merge with no write, use the merge helper.
store.mergeSet('user', { name: 'Bob' }); // keeps other fields of user, updates name
reset
reset(keys?: (keyof T)[]): void
Resets keys to the initial state passed at creation. With no argument, resets every key. With an array, resets only those keys. When a key has a transforming schema, the initial state was committed post-transform, and reset restores that value.
store.reset(); // all keys back to initial
store.reset(['count']); // only count
reset covers this store's own leaves. Children are independent stores and keep their state — reset them directly, and clear lookups with clear(). A full logout looks like:
app.reset();
app.select('profile').reset();
app.select('todos').clear();
batch
batch(fn: () => void): void
Runs fn, coalescing every write inside it into one notification round. Each affected listener is called once after fn returns. Nested batch calls join the outer batch. See batching.
store.batch(() => {
store.setKey('count', 10);
store.increment('count', 5);
});
subscribe
subscribe(keys: (keyof T)[], listener: () => void): () => void
Registers a listener for a set of keys and returns an unsubscribe function. The listener runs when any of those keys changes. It receives no arguments; read current values with getKey or get. For change detection with old and new values, see onChange.
const off = store.subscribe(['count'], () => {
console.log('count is now', store.getKey('count'));
});
store.setKey('count', 1); // logs: count is now 1
off(); // listener removed
store.setKey('count', 2); // logs nothing
pick
pick<K extends keyof T>(keys: K[]): Pick<T, K>
Returns a snapshot object with the requested keys. Imperative, synchronous, and does not subscribe. (Navigation into child nodes is Store.select, a different method.)
const { count, name } = store.pick(['count', 'name']);
intercept
intercept(fn: Interceptor<T>, keys?: (keyof T)[]): () => void
type Interceptor<T> = (update: Partial<T>, state: T) => Partial<T> | false | void
Intercepts writes before they commit. The function receives the pending update and the pre-update state, and answers with its return value: a partial replaces the update, false vetoes it, and returning nothing passes it through unchanged. With keys, it runs only when the update touches one of them. Interceptors run in registration order, a replacement flows into the next one, and a thrown error blocks the write and is logged. Returns a function that removes the interceptor.
// Clamp count into range on every write that includes it
const remove = store.intercept((update) => {
if (typeof update.count === 'number') {
return { ...update, count: Math.max(0, Math.min(100, update.count)) };
}
}, ['count']);
// Veto deletes while a sync is running
store.intercept((update, state) => {
if (state.syncing && update.items) return false;
}, ['items']);
Writes made through setKey, update, increment, toggle, mergeSet, and reset all run through your interceptors once any is registered. See the middleware guide.
onChange
onChange<K extends keyof T>(
keys: K[],
callback: (values: Pick<T, K>, prev: Pick<T, K>) => void
): () => void
Reacts to key changes outside React, with both the new and previous values. Synchronous writes in the same tick are coalesced and the callback fires once on a microtask with the final values. Returns an unsubscribe function. See reacting outside React.
const off = store.onChange(['count'], (values, prev) => {
console.log(prev.count, '->', values.count);
});
derive
derive<K extends keyof T, I extends keyof T>(
outKey: K,
inputs: I[],
compute: (values: Pick<T, I>) => T[K]
): () => void
Derives outKey from inputs. compute runs once immediately to set the initial value, then runs synchronously inside any write that changes an input and commits its result in the same update. Subscribers to outKey wake only when the computed value actually changes. Returns a disposer that stops deriving. See derived keys.
compute always receives the current values of all declared inputs, including the ones a write didn't touch. It runs on every write that touches an input, so keep it cheap.
const store = createStore({ first: 'Ada', last: 'Lovelace', full: '' });
store.derive('full', ['first', 'last'], ({ first, last }) => `${first} ${last}`);
store.setKey('first', 'Grace'); // full becomes 'Grace Lovelace' in the same update
useStoreKey
function useStoreKey<T extends object, K extends keyof T>(
store: StoreType<T>,
key: K
): T[K]
Subscribes to one key and returns its value. The component re-renders only when that key changes (Object.is, plus any options.equals). Works on any store, including one from a lookup: useStoreKey(lookup.at(id), 'title').
const count = useStoreKey(store, 'count');
useStoreSelector
function useStoreSelector<T extends object, S extends SelectorInput<T>>(
store: StoreType<T>,
selector: S
): Picked<T, S>
type SelectorInput<T> = ReadonlyArray<keyof T | { [K in keyof T]?: (prev: T[K], next: T[K]) => boolean }>
Subscribes to several keys and returns just those keys. The selector is always an array; each element is a key string or a { key: compareFn } object. The component re-renders when a selected key changes. A compare function makes that call for its key: return true to treat two values as equal and skip the re-render. Store-level options.equals applies when no per-key compare is given.
// Keys
const { count, name } = useStoreSelector(store, ['count', 'name']);
// Custom compare for one key
const { tasks } = useStoreSelector(store, [
{ tasks: (prev, next) => prev.length === next.length },
]);
// Mixed
const { count, tasks } = useStoreSelector(store, [
'count',
{ tasks: (prev, next) => prev.length === next.length },
]);
Key-string selectors can be inline array literals; they are matched by value. A selector containing a compare function is matched by identity, so define it outside render (or memoize it) to avoid re-subscribing every render.
createStoreHook
function createStoreHook<T extends object>(
store: StoreType<T>
): <S extends SelectorInput<T>>(selector: S) => Picked<T, S>
Returns a hook bound to a store, with the same selector API as useStoreSelector. Types are inferred from the store.
const useMyStore = createStoreHook(myStore);
function View() {
const { count, name } = useMyStore(['count', 'name']);
return <span>{count} {name}</span>;
}
Helpers
Standalone functions, imported from @zaatar-tech/voltix.
merge (helper)
function merge<T extends object>(base: T, patch: Partial<T>): T
Shallow-merge on plain data: { ...base, ...patch }, typed so the patch can only carry keys of the base. Pair it with a write when the result should be stored, or use mergeSet, which merges and writes in one call.
const preview = merge(store.getKey('user'), { name: 'Bob' }); // computed, nothing written
persist
function persist<T extends object>(
store: StoreType<T>,
storage: Storage | StorageSupportingInterface | AsyncStorageSupportingInterface,
persistKey: string,
keys: (keyof T)[],
parser?: PersistParser
): () => void
Persists the given keys: each one is written to storage as it changes, under `${persistKey}:${keyName}`. Works with synchronous storage (localStorage, sessionStorage) and asynchronous storage (React Native AsyncStorage); async writes are fire-and-forget, so a slow write never blocks the state update. Returns an unsubscribe that stops persisting. See persistence.
The default format is JSON plus Date, Map, Set, and non-finite numbers, which all survive the round-trip. To store other types, pass a parser — the same one to loadPersistedState.
persist(store, localStorage, 'myapp', ['theme', 'isLoggedIn']);
loadPersistedState
function loadPersistedState<T extends object>(
storage: Storage | StorageSupportingInterface,
persistKey: string,
keys: (keyof T)[],
parser?: PersistParser
): Partial<T>
function loadPersistedState<T extends object>(
storage: AsyncStorageSupportingInterface,
persistKey: string,
keys: (keyof T)[],
parser?: PersistParser
): Promise<Partial<T>>
Reads keys written by persist and returns them as a partial state. Returns synchronously for sync storage and a Promise for async storage. Use it to seed the initial state at creation, or to hydrate a store after creation. Pass the same parser given to persist, if any.
// Sync
const persisted = loadPersistedState(localStorage, 'myapp', ['theme']);
const store = createStore({ theme: 'light', ...persisted });
// Async
const persisted = await loadPersistedState(AsyncStorage, 'myapp', ['theme']);
const store = createStore({ theme: 'light', ...persisted });
Storage interfaces
interface StorageSupportingInterface {
getItem(key: string): string | null;
setItem(key: string, value: string): void;
}
interface AsyncStorageSupportingInterface {
getItem(key: string): Promise<string | null>;
setItem(key: string, value: string): Promise<void>;
}
interface PersistParser {
stringify(value: any): string;
parse(text: string): any;
}
The persistence functions accept the DOM Storage type, any object matching StorageSupportingInterface (sync), or any object matching AsyncStorageSupportingInterface (async, such as React Native AsyncStorage).
A PersistParser controls how values become strings. The default handles JSON plus Date, Map, Set, and non-finite numbers. Anything with stringify/parse fits: JSON gives you raw behaviour, superjson covers BigInt, RegExp, and registered classes. Use one parser consistently per prefix.
ESLint plugin (@zaatar-tech/voltix/eslint)
Voltix ships an ESLint plugin at the @zaatar-tech/voltix/eslint subpath with one rule, voltix/no-unused-selector-keys. Every key in a selector is a subscription, so a selected-but-unused key means re-renders for data the component never reads. The rule warns on exactly that:
const { count } = useStoreSelector(store, ['count', 'name']);
// ^ 'name' is selected but never destructured
It works with zero configuration. useStoreSelector(store, [...]) calls are recognized by name; a hook made with createStoreHook is recognized by how it's used — any use*-named call taking a selector array whose result is destructured, in any file. Both forms include { key: compareFn } custom-compare entries. Requires ESLint 9+ (flat config).
// eslint.config.js
import voltix from '@zaatar-tech/voltix/eslint';
export default [
{
plugins: { voltix },
rules: { 'voltix/no-unused-selector-keys': 'warn' },
},
];
The recommended preset is also available for spreading:
import voltix from '@zaatar-tech/voltix/eslint';
export default [voltix.configs.recommended];
The rule warns only when it can prove a key is unused; anything unclear is skipped:
- The result must be destructured inline. A
...restelement covers the remaining keys only when the rest variable is read somewhere — a rest nobody touches proves nothing, and the other keys are still checked. - For a hook recognized by usage (anything other than a literal
useStoreSelectorcall), at least one selected key must appear in the destructuring — that overlap is what marks it as a selector call, so an unrelated hook that happens to take a string array is left alone.
const { items } = useCart(['items', 'total']); // provably unused 'total' — warns
const slice = useCart(['items', 'total']); // not destructured — skipped
const { items, ...rest } = useCart(['items', 'total']);
render(rest); // rest is read — skipped
const { items, ...rest } = useCart(['items', 'total']); // rest never read — 'total' warns
const { data } = useQueries(['users', 'posts']); // no overlap — not a selector call, skipped