Every export from @wildgrowth/voltix, plus every method on a store. Types are shown as they appear in the source. T is your state shape.
@wildgrowth/voltix
T
@wildgrowth/voltix/vanilla has the same exports without useStoreKey, useStoreSelector and createStoreHook, and runs without React. Both entries make the same stores, so a store from one nests in a store from the other.
@wildgrowth/voltix/vanilla
useStoreKey
useStoreSelector
createStoreHook
Contents:
at
has
remove
keys
size
clear
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`.
TypeError
import { createStore } from '@wildgrowth/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.
equals
(prev, next)
true
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.
schema
LeafState<T> is T with its child-store keys removed, so options and every store method see only the reactive leaves.
LeafState<T>
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.
at(id)
factory
options
import { createLookup } from '@wildgrowth/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.
S
Store<C>
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').
id
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.
has(id)
remove(id)
keys()
size()
clear()
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.
createStore(shape)
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).
key
RangeError
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).
define
select(key)
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.
{ ...baseActions, ...adminActions }
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.
options.schema
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 '@wildgrowth/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:
set
setKey
update
increment
toggle
mergeSet
reset
options.equals
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.
Starting values are checked at construction, so a bad one is caught immediately. Transforms apply to them too, and reset() restores the transformed value.
reset()
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.
localStorage
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; });
A Store implements StoreType<LeafState<T>>. Every method operates on the reactive leaf keys.
StoreType<LeafState<T>>
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(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.
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<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.
set({ [key]: value })
store.setKey('count', 5);
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(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.
amount
1
store.increment('count'); // + 1 store.increment('count', 10); // + 10 store.increment('count', -1); // - 1
toggle(key: BooleanKeys<T>): void
Flips a boolean key. Allowed only on keys whose value is a boolean.
store.toggle('isOpen');
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(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.
Stores without child nodes or schemas retain the original input object for resets. Changing its top-level properties after createStore changes the values that reset restores, so treat that object as immutable. Stores with child nodes or schemas keep a shallow copy of their initial leaf values instead.
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 subscription is notified once after fn returns. Separate subscriptions remain independent even if they use the same callback. Nested batch calls join the outer batch. See batching.
fn
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`.
Each subscribe call registers an independent subscription, even when the same callback or keys are reused. A single subscribe(['a', 'b'], fn) runs fn once when both keys change in one set or batch; two separate subscriptions using fn run it once per affected subscription. Each unsubscribe removes only its own subscription.
subscribe(['a', 'b'], fn)
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.
false
// 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.
outKey
inputs
compute
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
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').
Object.is
useStoreKey(lookup.at(id), 'title')
const count = useStoreKey(store, 'count');
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.
{ key: compareFn }
// 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.
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>; }
Standalone functions, imported from @wildgrowth/voltix.
merge
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.
{ ...base, ...patch }
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.
. Works with synchronous storage (
,
) and asynchronous storage (React Native
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.
Date
Map
Set
loadPersistedState
persist(store, localStorage, 'myapp', ['theme', 'isLoggedIn']);
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.
Promise
parser
// 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 });
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).
Storage
StorageSupportingInterface
AsyncStorageSupportingInterface
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.
PersistParser
stringify
parse
JSON
@wildgrowth/voltix/eslint
Voltix ships an ESLint plugin at the @wildgrowth/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:
voltix/no-unused-selector-keys
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).
useStoreSelector(store, [...])
use*
// eslint.config.js import voltix from '@wildgrowth/voltix/eslint'; export default [ { plugins: { voltix }, rules: { 'voltix/no-unused-selector-keys': 'warn' }, }, ];
The recommended preset is also available for spreading:
import voltix from '@wildgrowth/voltix/eslint'; export default [voltix.configs.recommended];
The rule warns only when it can prove a key is unused; anything unclear is skipped:
...rest
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