Another state library? Yes, but a different kind. State libraries make you choose: one big store (Zustand) or scattered atoms (Jotai). ValUse gives you scopes: structured, reactive models with typed fields, derived state, and lifecycle hooks built in, so your state mirrors how your data actually works instead of how your framework wants it. Creating independent instances doesn't require factory wrappers or providers.
npm install valuseimport { value, valueScope } from 'valuse';
const inboxScope = valueScope(
{
userId: value<string>(),
lastReadAt: value<number>(0),
},
{
// Async derivation. Polls for notifications; aborts and restarts when userId changes.
notifications: async ({ scope, set, signal, deferBy }) => {
while (!signal.aborted) {
const res = await fetch(`/api/notifications/${scope.userId.use()}`, {
signal,
});
set(await res.json());
await deferBy(30_000);
}
},
},
{
// Sync derivation. Reads the async one like any other field.
unreadCount: ({ scope }) => {
const notifs = scope.notifications.use() ?? [];
const readAt = scope.lastReadAt.use();
return notifs.filter((n) => n.ts > readAt).length;
},
},
);Create an instance, interact with it:
const inbox = inboxScope.create({ userId: 'alice' });
inbox.unreadCount.get(); // 3. Recomputes as notifications poll in.
inbox.lastReadAt.set(Date.now()); // unreadCount drops; polling undisturbed.
inbox.userId.set('bob'); // Old poll aborts, new one starts.
inbox.notifications.recompute(); // Skip the wait, poll now.Let's compare: Overview | Zustand | Jotai | MobX | Valtio | React Context
What can you build with it?
- Another todo list, because the world needed one more
- A form wizard with validation, dynamic fields, and cross-step state
- A real-time stock ticker with WebSocket feeds (GME to the moon)
- A kanban board with drag-and-drop between columns
- A command-palette search with cascading async
derivations, in-derivation debounce via
deferBy(), and Enter-to-flush() - A Next.js search-params sync that hydrates from
the URL and writes back via
onChange - Middleware for logging, persistence, undo/redo
valueScope() takes one or more layers: fields first, then zero or more
derivation layers, then an optional config layer. The layered shape lets
TypeScript fully infer scope inside every derivation without a manual type
annotation. It also makes the dependency order visible at the call site and
makes circular derivations structurally impossible.
In React, import the side-effect bridge once anywhere in your app. It wires up
useSyncExternalStore so .use() hooks re-render on change:
import 'valuse/react';
function UnreadBadge({ inbox }) {
const [unreadCount] = inbox.unreadCount.use();
return unreadCount > 0 ? <span className="badge">{unreadCount}</span> : null;
}A value wraps a piece of state with read, write, and subscription, plus
optional transforms and custom equality. Every other reactive type in ValUse
(valueArray, valueSet, valueMap, schema-validated values, scopes) builds
on the same core surface.
Deep dive: docs/reactive-values.md
import { value } from 'valuse';
const userId = value<string>('alice');
const pollInterval = value<number>(30_000);Read, write, and subscribe. No framework required:
userId.get(); // 'alice'
userId.set('bob');
userId.set((prev) => prev.toLowerCase()); // callback form
userId.subscribe((v) => console.log(v)); // logs on every changeIn React, .use() returns the current value and re-renders on change:
const [currentUser, setUser] = userId.use();Deep dive: docs/reactive-values.md#collections
Reactive versions of Array, Set, and Map. Same core interface: .get(),
.set(), .use(), .subscribe().
import { valueArray, valueSet, valueMap } from 'valuse';
const notifications = valueArray<Notification>();
notifications.set([notifA, notifB]);
notifications.push(notifC);
notifications.get(); // [notifA, notifB, notifC] (frozen)
const channels = valueSet<string>(['inbox', 'mentions']);
channels.add('updates');
channels.delete('mentions');
channels.has('updates'); // true
const unreadByChannel = valueMap<string, number>([
['inbox', 3],
['mentions', 1],
]);
unreadByChannel.get('inbox'); // 3
unreadByChannel.delete('mentions');valueMap supports per-key subscriptions in React:
const [inboxCount, setInbox] = unreadByChannel.use('inbox'); // only re-renders when inbox changes
const keys = unreadByChannel.useKeys(); // only re-renders when keys changevalueArray supports per-index subscriptions:
const [latest, setLatest] = notifications.use(0); // only re-renders when index 0 changesDeep dive: docs/pipes.md
Chain .pipe() to transform values on every .set(). Pipes run in order before
the value is stored:
const email = value<string>('')
.pipe((v) => v.trim())
.pipe((v) => v.toLowerCase());Pipes can change the type. set() accepts the input type, get() returns the
output type:
const count = value<string>('0').pipe((v) => parseInt(v));
count.set('42'); // accepts string
count.get(); // returns number: 42valueArray supports per-element transforms with pipeElement():
const names = valueArray<string>().pipeElement((s) => s.trim().toLowerCase());
names.push(' Hello '); // subscribers see 'hello'By default, values notify subscribers on identity change (===). Override with
.compareUsing():
const notification = value<Notification>({
id: 'n1',
text: '...',
}).compareUsing((a, b) => a.id === b.id);valueArray has compareElementsUsing() for per-element comparison:
const notifications = valueArray<Notification>().compareElementsUsing(
(a, b) => a.id === b.id,
);When a value has both pipes and a custom comparator, the order is:
- set(): raw input enters.
- pipe chain: transforms run left to right.
- compareUsing(): compared against current value.
- write: if different, subscribers are notified.
This means comparison runs on the post-pipe value, not the raw input.
Group multiple writes so subscribers fire once:
import { batchSets } from 'valuse';
batchSets(() => {
userId.set('bob');
pollInterval.set(60_000);
});
// Subscribers notified once, not twiceA scope bundles related state into a reusable template. Call .create() to
produce as many independent instances as you need; each owns its own signals,
its own derivations, and its own lifecycle.
A scope definition can mix:
- Reactive fields:
value(), collections (valueArray(),valueSet(),valueMap()),valueSchema()for schema-validated values, andvaluePlain()for non-reactive bookkeeping. - Derivations, sync or async, that read other fields and recompute when their dependencies change.
- Nested objects in the field layer, so you can
write
scope.preferences.emailwithout creating a separate scope. A nested object is just a plain object whose entries follow the same field-layer rules. - Refs to other scopes via
valueRef(), so reactivity and lifecycle flow across template boundaries (shared globally, or per-instance via a factory). - Lifecycle hooks in the
config layer:
onCreate,onUsed,onUnused,onDestroy, plusbeforeChange/onChangefor side effects andvalidatefor cross-field rules.
Scopes also compose:
.extendValues() and .extendConfig() layer extra values,
derivations, and hooks onto an existing template, which makes middleware just a
function from scope to scope.
Fields are accessed as properties on the instance, each with .get(), .set(),
and .use(). Derivations have the same surface minus .set().
Deep dive: docs/scopes.md | docs/derivations.md
const inboxScope = valueScope(
{
userId: value<string>(),
lastReadAt: value<number>(0),
channels: valueSet<string>(['inbox']),
},
{
notifications: async ({ scope, set, signal, deferBy }) => {
while (!signal.aborted) {
const res = await fetch(`/api/notifications/${scope.userId.use()}`, {
signal,
});
set(await res.json());
await deferBy(30_000);
}
},
},
{
unreadCount: ({ scope }) => {
const notifs = scope.notifications.use() ?? [];
const readAt = scope.lastReadAt.use();
return notifs.filter((n) => n.ts > readAt).length;
},
},
);const inbox = inboxScope.create({
userId: 'alice',
// lastReadAt defaults to 0
// channels defaults to Set { 'inbox' }
});
const empty = inboxScope.create(); // all undefined or defaultsEach reactive field (value(), valueArray(), valueSet(), valueMap(),
valueSchema(), valueRef()) exposes .get(), .set(), .use(), and
.subscribe(). Derivations have the same except .set():
inbox.userId.get(); // 'alice'
inbox.userId.set('bob');
inbox.userId.set((prev) => prev.toLowerCase()); // callback form
inbox.channels.add('mentions');
inbox.channels.get(); // Set { 'inbox', 'mentions' }
inbox.unreadCount.get(); // 5
// inbox.unreadCount.set() -- doesn't exist; derivations are read-onlyIn React:
const [userId, setUserId] = inbox.userId.use();
const [unreadCount] = inbox.unreadCount.use(); // derivation, no setterInstance-level methods use a $ prefix to separate them from field names:
inbox.$get(); // resolved values, scope refs stay live
inbox.$getSnapshot(); // plain data, recursively resolved
inbox.$setSnapshot({ userId: 'bob', lastReadAt: Date.now() });
inbox.$use(); // React hook, re-renders on any change
inbox.$subscribe(fn); // whole-scope subscribe
inbox.$recompute(); // re-run all derivations
inbox.$destroy(); // tear down instance$getSnapshot() resolves everything recursively to plain data, including across
scope ref boundaries. $get() stops at scope refs, leaving them as live
instances.
$setSnapshot() accepts a nested partial. Only reactive fields are written:
inbox.$setSnapshot({
preferences: { email: true }, // updates preferences.email, leaves others alone
});To re-run lifecycle hooks during a snapshot restore, pass
{ recreate: true }. The instance steps through:
- Aborts the previous
onCreatesignal. - Fires all registered cleanups.
- Runs
onDestroy. - Applies the snapshot.
- Runs
onCreatefresh.
inbox.$setSnapshot(savedState, { recreate: true });Scope definitions support nesting. Reactive value()
nodes can appear at any depth, with plain data as static readonly leaves:
const inboxScope = valueScope(
{
userId: value<string>(),
schemaVersion: 1, // plain data, readonly, not reactive
preferences: {
email: value<boolean>(true),
push: value<boolean>(false),
},
},
{
summary: ({ scope }) => {
const user = scope.userId.use();
const email = scope.preferences.email.use();
const push = scope.preferences.push.use();
return `${user}: email=${email}, push=${push}`;
},
},
);
const inbox = inboxScope.create({
userId: 'alice',
preferences: { email: true, push: true },
});
inbox.preferences.push.get(); // true
inbox.preferences.push.set(false);
inbox.schemaVersion; // 1, just a value, no .get()For cross-scope composition (sharing state between independent scopes), use
valueRef instead of nesting.
Deep dive: docs/derivations.md
Derivations are functions that compute values from other fields. They receive a
scope context for reading state:
const inboxScope = valueScope(
{
userId: value<string>(),
lastReadAt: value<number>(0),
notifications: valueArray<Notification>(),
},
{
// .use() tracked. Re-runs when notifications or lastReadAt change.
// .get() untracked. Reads userId without re-running when it changes.
unreadCount: ({ scope }) => {
const notifs = scope.notifications.use();
const readAt = scope.lastReadAt.use();
const owner = scope.userId.get();
return notifs.filter((n) => n.ts > readAt && n.recipient === owner)
.length;
},
},
);.use(): tracked read. The derivation re-runs when this value changes..get(): untracked read. Current value, no dependency.
A derivation with zero .use() calls is a constant; it runs once and never
recomputes. Call .recompute() on any derivation to manually trigger a re-run.
When a derivation is async, ValUse automatically manages abort, status
tracking, and cleanup. Here a user profile is fetched with
stale-while-revalidate from a local cache:
const profileScope = valueScope(
{ userId: value<string>() },
{
profile: async ({ scope, set, signal }) => {
const id = scope.userId.use();
const cached = sessionStorage.getItem(`profile:${id}`);
if (cached) set(JSON.parse(cached)); // show cached immediately
const res = await fetch(`/api/users/${id}`, { signal });
const fresh = await res.json();
sessionStorage.setItem(`profile:${id}`, JSON.stringify(fresh));
return fresh; // replaces cached value
},
},
);
const profile = profileScope.create({ userId: 'alice' });When userId changes, the previous fetch is aborted via signal and a new one
starts. set() pushes the cached value immediately so the UI never shows an
empty state. When the fetch resolves, return replaces it with fresh data.
Async derivations have an AsyncState for status tracking:
const [profileData, profileState] = profile.profile.useAsync();
if (profileState.isPending) return <Spinner />;
if (profileState.isError) return <Error error={profileState.error} />;
return <Avatar user={profileData} />;.use() returns [T | undefined] (just the value, no status). Use
.useAsync() when you need the state alongside it.
Sync derivations can depend on async ones without knowing they're async.
.use() returns T | undefined; no promises, no await:
const profileScope = valueScope(
{ userId: value<string>() },
{
profile: async ({ scope, set, signal }) => {
const id = scope.userId.use();
const cached = sessionStorage.getItem(`profile:${id}`);
if (cached) set(JSON.parse(cached));
const res = await fetch(`/api/users/${id}`, { signal });
return res.json();
},
},
{
// Sync. Just sees Profile | undefined. Recomputes when profile resolves.
initials: ({ scope }) => {
const p = scope.profile.use();
if (!p) return '';
return p.firstName[0] + p.lastName[0];
},
},
);If you later change profile from sync to async (or vice versa), initials
doesn't change at all.
You can also seed an async derivation with cached data at creation time:
const profile = profileScope.create({
userId: 'alice',
profile: cachedProfile, // available immediately via .get(), replaced when fetch resolves
});Deep dive: docs/async-derivations.md
Any entry in the field layer that isn't a reactive primitive or a nested object is treated as static readonly data. It travels with the instance but doesn't participate in reactivity:
const inboxScope = valueScope({
userId: value<string>(),
schemaVersion: 1,
defaultPrefs: { pollMs: 30_000, maxItems: 50 },
});
const inbox = inboxScope.create({ userId: 'alice' });
inbox.schemaVersion; // 1
inbox.defaultPrefs; // { pollMs: 30_000, maxItems: 50 } (frozen)For non-reactive data that you still need to read and write, use valuePlain().
It has .get() and .set() but is invisible to the reactive graph. Changes
won't trigger re-renders or re-derivations:
const inboxScope = valueScope({
userId: value<string>(),
metadata: valuePlain({ lastSyncedAt: 0 }),
config: valuePlain({ pollMs: 30_000 }, { readonly: true }),
});
const inbox = inboxScope.create({ userId: 'alice' });
inbox.metadata.get(); // { lastSyncedAt: 0 }
inbox.metadata.set({ lastSyncedAt: Date.now() });
inbox.config.set({ pollMs: 60_000 }); // throws, readonlyWhen working with external data that has more properties than your scope
declares (e.g., rich text nodes, API responses), use allowUndeclaredProperties
to preserve the extras as plain, non-reactive data:
const baseNode = valueScope(
{
id: value<string>(),
type: value<string>(),
isHighlighted: value<boolean>(false),
},
{ allowUndeclaredProperties: true },
);
const nodes = baseNode.createMap();
nodes.set('node-1', slateNode);
// id, type, isHighlighted: reactive
// text, children, bold, italic: preserved but not reactiveThere are two ways to wire side effects to state changes:
subscribe to a specific field or a
whole instance for a value-as-it-changes stream, or use the config layer's
beforeChange /
onChange hooks to intercept and respond to
writes with full structured change metadata. beforeChange runs synchronously
before each write and can prevent() it; onChange runs after a batch of
writes settles and tells you which fields and which subscopes changed.
Deep dive: docs/change-hooks.md
Each reactive field on a scope instance has .subscribe():
inbox.lastReadAt.subscribe((value, previousValue) => {
console.log(`read marker moved: ${previousValue} → ${value}`);
});inbox.$subscribe(() => {
console.log('something changed:', inbox.$getSnapshot());
});Fires after mutations. Batched: multiple synchronous sets produce one call. Uses
changesByScope to check which parts of the tree changed:
const inboxScope = valueScope(
{
userId: value<string>(),
lastReadAt: value<number>(0),
lastSyncedAt: value<number>(0),
preferences: {
email: value<boolean>(true),
},
},
{
onChange: ({ scope, changes, changesByScope }) => {
if (changesByScope.has(scope.preferences)) {
console.log('preferences changed');
}
scope.lastSyncedAt.set(Date.now());
},
},
);Fires synchronously before each value is written. Use prevent() to block the
write. Derivations never see prevented values.
Unlike onChange, beforeChange is per-write, not batched: it fires once
for each .set() call with changes.size === 1. batchSets defers downstream
effect propagation but does not collapse beforeChange invocations; each write
is independently veto-able.
const inboxScope = valueScope(
{
userId: value<string>(),
preferences: {
email: value<boolean>(true),
},
},
{
beforeChange: ({ scope, changes, prevent }) => {
// `changes` always holds exactly one change here.
const [change] = changes;
// Prevent a specific field
if (change.path === 'userId') prevent(change);
// Prevent everything under a nested subtree
if (change.to === '') prevent(scope.preferences);
// Prevent based on the change itself
if (change.to === null) prevent(change);
},
},
);Beyond a single scope instance, ValUse covers the patterns that usually come
next: keyed collections of the same shape (ScopeMap),
composition across independent scopes (valueRef), derived
templates and middleware
(.extendValues() / .extendConfig()),
long-running async work,
lifecycle setup and teardown,
schema validation, and a small kit of shipped
middleware (devtools, persistence,
undo/redo).
Deep dive: docs/scope-map.md
When you need many instances of the same scope (rows, list items, entries),
.createMap() supports several hydration styles:
// Empty collection (e.g., multi-account inboxes)
const inboxes = inboxScope.createMap();
// From an array, keyed by field name
const inboxes = inboxScope.createMap(accounts, 'userId');
// From an array, keyed by callback
const inboxes = inboxScope.createMap(accounts, (acct) => acct.userId);
// From a Map
const inboxes = inboxScope.createMap(
new Map([
['alice', { userId: 'alice', lastReadAt: 1717700000 }],
['bob', { userId: 'bob', lastReadAt: 1717690000 }],
]),
);Add, update, and remove entries after creation:
inboxes.set('carol', { userId: 'carol' });
inboxes.delete('carol'); // fires onDestroy for that instance
inboxes.has('carol'); // boolean
inboxes.keys(); // string[]
inboxes.size; // number of entries
inboxes.clear(); // remove all, fires onDestroy for eachAccess fields directly on the instance:
const alice = inboxes.get('alice');
alice.lastReadAt.get(); // 1717700000
alice.lastReadAt.set(Date.now());
alice.$destroy();In React:
function InboxRow({ inbox }) {
const [unreadCount] = inbox.unreadCount.use();
const [userId] = inbox.userId.use();
return (
<li>
{userId}: {unreadCount} unread
</li>
);
}
function AccountList({ inboxes }) {
const keys = inboxes.useKeys();
return keys.map((id) => <InboxRow key={id} inbox={inboxes.get(id)} />);
}Deep dive: docs/refs.md
Use valueRef() to bring external reactive state into a scope. Refs are shared
across all instances. They point to the same source, not a copy:
import { valueRef } from 'valuse';
const connectionStatus = value<'online' | 'offline'>('online');
const inboxScope = valueScope({
userId: value<string>(),
connection: valueRef(connectionStatus),
});Per-instance refs with factories. Each instance gets its own nested collection:
const channelScope = valueScope({
id: value<string>(),
name: value<string>(),
muted: value<boolean>(false),
});
const inboxScope = valueScope(
{
userId: value<string>(),
channels: valueRef(() => channelScope.createMap()),
},
{
activeChannels: ({ scope }) => scope.channels.use().size,
},
);Reactivity flows through refs. A derivation that reads a ref's fields via
use() will re-run when those fields change, just like any other dependency.
Lifecycle is transitive too. When a scope
gets its first subscriber (triggering onUsed),
all scopes it references via valueRef() also become "used," activating their
onUsed hooks and async derivations. When the last subscriber detaches,
referenced scopes are notified as well.
Deep dive: docs/extending.md
.extendValues() adds new state and derivations; .extendConfig() attaches
lifecycle hooks. Both return new templates that include everything from the
original:
const trackedInbox = inboxScope
.extendValues({ lastSyncedAt: value<number>(Date.now()) })
.extendConfig({
onChange: ({ scope }) => {
scope.lastSyncedAt.set(Date.now());
},
});Remove fields with undefined:
const simplified = inboxScope.extendValues({
preferences: undefined, // removes the nested preferences object. TypeScript catches broken refs.
});Since .extendValues() and .extendConfig() take a scope and return a scope,
middleware is just a function:
const withTracking = (scope) =>
scope.extendValues({ lastSyncedAt: value<number>(Date.now()) }).extendConfig({
onChange: ({ scope }) => scope.lastSyncedAt.set(Date.now()),
});
const withArchive = (scope) =>
scope.extendValues({ archived: value<boolean>(false) });
const fullInbox = withArchive(withTracking(inboxScope));Deep dive: docs/async-derivations.md
Every async derivation has an AsyncState<T>:
interface AsyncState<T> {
value: T | undefined;
hasValue: boolean;
status: 'unset' | 'setting' | 'set' | 'error';
error: unknown;
isPending: boolean; // setting && !hasValue; "first load, show a spinner"
isUpdating: boolean; // setting && hasValue; new value computing, keep current on screen
isError: boolean; // status === 'error'
}- Starts
'unset', no value yet - While running:
'setting'(preserves previous value) - On resolve:
'set' - On reject:
'error'(preserves previous value)
Push values before the final return. Useful for optimistic updates, streaming,
and progress reporting:
notifications: async ({ scope, set, signal }) => {
const id = scope.userId.use();
const cached = localStorage.getItem(`notifs:${id}`);
if (cached) set(JSON.parse(cached)); // show cached immediately
const res = await fetch(`/api/notifications/${id}`, { signal });
return res.json(); // replace with fresh data
},Register cleanup functions with onCleanup(). They run when the derivation
re-runs or when the instance is destroyed:
notifications: async ({ scope, set, onCleanup }) => {
const es = new EventSource(`/api/notifications/${scope.userId.use()}/stream`);
onCleanup(() => es.close());
es.onmessage = (e) => set((prev) => [...(prev ?? []), JSON.parse(e.data)]);
},.use() works anywhere in async derivations, before or after await.
Dependencies are tracked eagerly via per-call subscriptions. If a tracked dep
changes mid-flight, the abort signal fires immediately and the derivation
re-runs:
notifications: async ({ scope, signal }) => {
const id = scope.userId.use();
const res = await fetch(`/api/notifications/${id}`, { signal });
const data = await res.json();
if (data.requiresUpgrade) {
const tier = scope.accountTier.use(); // works after await
return fetchWithTier(`/api/notifications/${id}`, tier, { signal });
}
return data;
},Since set() can push values at any point during execution, putting it inside a
loop creates a long-running process. This is a natural fit for polling,
WebSocket streams, or any open-ended data source:
const inboxScope = valueScope(
{ userId: value<string>() },
{
notifications: async ({ scope, set, signal, deferBy }) => {
const id = scope.userId.use();
if (!id) return;
while (!signal.aborted) {
const res = await fetch(`/api/notifications/${id}`, { signal });
if (!signal.aborted) set(await res.json());
await deferBy(30_000);
}
// Loop runs until signal aborts; values come from set()
},
},
);When userId changes, the previous loop is aborted and a new one starts. When
the instance is destroyed, it's aborted automatically.
Deep dive: docs/lifecycle.md
| Hook | When it fires |
|---|---|
onCreate |
Once, when instance is created |
onDestroy |
When instance is destroyed |
onUsed |
Subscriber count transitions from 0 to 1 |
onUnused |
Subscriber count transitions from 1 to 0 |
These four are the instance-lifetime hooks. The change-related hooks
(beforeChange, onChange) and validate live in the same config layer; see
the Scope config reference for the full list.
onCreate and onUsed provide signal and onCleanup for automatic teardown.
onCreate also receives input, the data passed to .create(), for cases
where you need to react to it before subscribers attach:
const inboxScope = valueScope(
{
userId: value<string>(),
isOnline: value<boolean>(navigator.onLine),
},
{
onCreate: ({ scope, signal, onCleanup }) => {
// signal: pass to APIs that accept it
window.addEventListener('online', () => scope.isOnline.set(true), {
signal,
});
window.addEventListener('offline', () => scope.isOnline.set(false), {
signal,
});
// onCleanup: for everything else
const heartbeat = setInterval(() => ping(scope.userId.get()), 60_000);
onCleanup(() => clearInterval(heartbeat));
},
},
);onCreate's signal aborts when the instance is destroyed. onUsed's signal
aborts when the last subscriber detaches, and is recreated fresh on the next
attach.
Destroy is a terminal state. After destroy() / $destroy():
- Reads still return the last value.
- Writes are silently dropped, along with any deferred work that crosses the boundary (debounced flushes, async resolutions).
- Subscribers stop firing.
- A second call is a no-op.
Since a scope is just a function return value, you can parameterize them:
const createInbox = (pollMs: number, endpoint: string) =>
valueScope(
{ userId: value<string>() },
{
notifications: async ({ scope, set, signal, deferBy }) => {
while (!signal.aborted) {
const res = await fetch(`${endpoint}/${scope.userId.use()}`, {
signal,
});
set(await res.json());
await deferBy(pollMs);
}
},
},
);
const fastInbox = createInbox(5_000, '/api/v2/notifications');
const legacyInbox = createInbox(60_000, '/api/v1/notifications');Deep dive: docs/schema-validation.md
valueSchema pairs a reactive value with a
Standard Schema-compliant validator. The value
holds whatever was last set; validation state is metadata available alongside
it, like AsyncState for async derivations. Works with ArkType, Zod, Valibot,
or any library that implements Standard Schema.
import { type } from 'arktype';
import type { StandardSchemaV1 } from '@standard-schema/spec';
import { valueSchema, valueScope } from 'valuse';
const Email = type('string.email');
const PollInterval = type('5000 <= number <= 300000');
const prefsScope = valueScope(
{
email: valueSchema(Email, ''),
pollMs: valueSchema(PollInterval, 30_000),
quietStart: valueSchema(type('0 <= number <= 23'), 22),
quietEnd: valueSchema(type('0 <= number <= 23'), 7),
},
{
validate: ({ scope }) => {
const issues: StandardSchemaV1.Issue[] = [];
if (scope.quietStart.use() === scope.quietEnd.use()) {
issues.push({
message: 'Quiet hours must have a range',
path: ['quietEnd'],
});
}
return issues;
},
},
);Types flow from the schema. No manual type annotations needed:
const prefs = prefsScope.create();
prefs.email.set('not-an-email');
prefs.email.get(); // 'not-an-email' (whatever was last set)
prefs.email.getValidation();
// { isValid: false, value: 'not-an-email', issues: [...] }
prefs.email.set('alice@example.com');
prefs.email.get(); // 'alice@example.com'
prefs.email.getValidation();
// { isValid: true, value: 'alice@example.com', issues: [] }ValidationState<In, Out> is a discriminated union on isValid: when valid,
validation.value is the schema's parsed Out; when invalid, it's the raw In
that was last set. For pure validators where input and output coincide this is
invisible, but for parsing morphs (type('string.numeric.parse')) it gives you
a clean way to read the parsed value after an isValid guard. .get() always
returns the input type.
In React, .useValidation() gives you the value, setter, and validation state
in one call. The first two slots match .use() so you can swap them without
rewiring. Fields show both per-field schema errors and cross-field errors from
validate (routed via path):
function EmailPref() {
const prefs = usePrefs();
const [email, setEmail, validation] = prefs.email.useValidation();
return (
<div>
<input value={email} onChange={(e) => setEmail(e.target.value)} />
{!validation.isValid && (
<span className="error">{validation.issues[0]?.message}</span>
)}
</div>
);
}
function SaveButton() {
const prefs = usePrefs();
const isValid = prefs.$useIsValid();
return (
<button type="submit" aria-disabled={!isValid}>
Save preferences
</button>
);
}$getIsValid() / $useIsValid() returns a boolean gate; $getValidation() /
$useValidation() returns { isValid, issues } with scope-relative paths so
you can render errors anywhere. Both pairs accept { deep: true } to walk
subscopes transitively, prefixing nested paths with the ref field name (and
ScopeMap entry key) so a child's path: ['email'] surfaces at the parent as
path: ['account', 'email'].
validate lives in the config layer alongside onCreate and the other
lifecycle hooks, but it isn't an event hook. It is a reactive derivation that
returns an Issue[], re-evaluating whenever a .use()'d dependency changes.
It composes with .extendConfig(): both base and extension validate rules
run, and issues are concatenated.
Async schemas are rejected at the type level; pair a sync schema with an async derivation if you need to check something like username availability.
ValUse ships three batteries-included middleware wrappers for the most common
scope patterns, plus storage adapters for withPersistence and standalone
connectDevtools / connectMapDevtools helpers. Everything lives at
valuse/middleware:
| Middleware | Purpose | Deep dive |
|---|---|---|
withDevtools |
Redux DevTools integration, timeline, time travel | docs/devtools.md |
withPersistence |
Sync state to localStorage, IndexedDB, or custom | docs/persistence.md |
withHistory |
Undo/redo with bounded depth and batched typing | docs/history.md |
Each one wraps a scope template and returns a new template with the behavior layered on:
import { valueScope, value } from 'valuse';
import {
withDevtools,
withPersistence,
withHistory,
localStorageAdapter,
} from 'valuse/middleware';
// Compose freely; each middleware takes and returns a ScopeTemplate.
const persistedInbox = withDevtools(
withPersistence(withHistory(inboxScope), {
key: 'inbox',
adapter: localStorageAdapter,
}),
{ name: 'inbox' },
);
const inbox = persistedInbox.create({ userId: 'alice' });
inbox.lastReadAt.set(Date.now());
inbox.$undo(); // history: revert "mark as read"
// also: persisted to localStorage, and visible in Redux DevToolsFor standalone values and ScopeMaps that don't flow through .extendConfig(),
the devtools package also exports connectDevtools(value, …) and
connectMapDevtools(map, …); see docs/devtools.md.
A handful of advanced primitives for when you need more than the common
patterns: factory pipes for stateful transforms
like debounce and throttle,
type-changing pipes that let a value's
input and output types diverge, manual
.recompute() for derivations that only
do untracked reads, and runtime type guards for middleware that
operates on unknown fields.
Deep dive: docs/pipes.md
For stateful, deferred transforms like debounce and throttle, .pipe() accepts
a factory object:
import { pipeDebounce, pipeThrottle, pipeScan } from 'valuse/utils';
const searchFilter = value<string>('')
.pipe((v) => v.trim())
.pipe(pipeDebounce(300));
const scrollPosition = value<number>(0).pipe(pipeThrottle(16));
const readHistory = value<string>('').pipe(
pipeScan((acc, v) => [...acc, v], []),
);
// set('n1') → ['n1'], set('n2') → ['n1', 'n2']Available factory pipes: pipeDebounce, pipeThrottle, pipeBatch,
pipeFilter, pipeScan, pipeUnique. Also available: pipeEnum (sync
transform that narrows values to an allowed set, falling back to the first
element for invalid input).
Pipes can change the type. set() accepts the input type, get() returns the
output type:
const flag = value<string>('')
.pipe((v) => v.trim())
.pipe((v) => v.length) // string → number
.pipe((v) => v > 0); // number → boolean
flag.set('hello'); // accepts string
flag.get(); // returns boolean: trueTrigger re-runs of derivations that use only .get() (untracked reads):
inbox.notifications.recompute(); // single derivation (aborts + restarts polling)
inbox.$recompute(); // all derivationsRuntime type narrowing for middleware and generic utilities:
import { isValue, isSchema, isPlain, isComputed, isScope } from 'valuse';
isValue(inbox.userId); // true, has .get(), .set(), .use()
isSchema(prefs.email); // true, has .getValidation(), .useValidation()
isPlain(inbox.metadata); // true, has .get(), .set(), no .use()
isComputed(inbox.unreadCount); // true, has .get(), .use(), no .set()
isScope(inbox); // true, scope instanceNote: a schema-validated field is also a value field, so
isValue(prefs.email)returnstruefor avalueSchemaslot. When narrowing, check the more specific predicate first:if (isSchema(field)) { ...validation-aware path... } else if (isValue(field)) { ...plain reactive value... }
| Export | Description |
|---|---|
value<T>() |
Reactive value, starts as undefined |
value<T>(default) |
Reactive value with default |
valueSet<T>() |
Reactive Set |
valueMap<K, V>() |
Reactive Map |
valueArray<T>() |
Reactive Array with index subscriptions |
valuePlain<T>(default) |
Non-reactive get/set container |
valueSchema(s, def) |
Schema-validated reactive value (Standard Schema) |
valueRef(source) |
Reference to external reactive state (shared) |
valueRef(() => source) |
Per-instance ref; factory called on each create() |
batchSets(fn) |
Group writes; subscribers fire once |
The runtime types of fields on a scope instance. Use these to annotate component props that accept a single field:
function EmailField({ field }: { field: FieldValueSchema<string, string> }) {
const [email, setEmail, validation] = field.useValidation();
// ...
}| Type | Produced by |
|---|---|
FieldValue<In, Out> |
value() |
FieldValueSchema<In, Out> |
valueSchema() |
FieldValueArray<T> |
valueArray() |
FieldValueSet<T> |
valueSet() |
FieldValueMap<K, V> |
valueMap() |
FieldValuePlain<T> |
valuePlain() |
FieldValueRef<T> |
valueRef() |
FieldDerived<T> |
sync derivation function |
FieldAsyncDerived<T> |
async derivation function |
For FieldValue<In, Out> and FieldValueSchema<In, Out>, both type parameters
equal T in the common case; they only diverge once a .pipe() or schema morph
changes the stored type.
The naming rule is mechanical for factory-produced fields: Field + PascalCase
of the factory name. Function-form derivations don't have a factory, so they use
FieldDerived and FieldAsyncDerived.
| Method | Description |
|---|---|
.get() |
Read the current value |
.set(value) |
Write a new value (callback form: prev => next) |
.use() |
React hook: [value, setter], re-renders on change |
.subscribe(fn) |
Listen for changes, returns unsubscribe |
.pipe(fn) |
Transform on set, chainable, can change type |
.pipe(factory) |
Factory pipe for stateful transforms |
.flush() |
Cascade flush of pipe chain (async, returns Promise) |
.compareUsing(fn) |
Custom equality check |
.destroy() |
Tear down all subscriptions |
| Method | Description |
|---|---|
.get() |
Read the full array (frozen) |
.get(index) |
Read element by index (negative supported) |
.length |
Number of elements |
.set(array) / .set(i, v) |
Replace whole array or by index |
.push() / .pop() |
Append / remove last |
.unshift() / .shift() |
Prepend / remove first |
.splice(start, count, ...) |
Remove and/or insert at position |
.filter(fn) / .map(fn) |
Transform array |
.sort(fn?) / .reverse() |
Sort / reverse |
.swap(i, j) |
Swap two indices |
.use() |
React hook: whole array |
.use(index) |
React hook: single index (negative ok) |
.pipeElement(fn) |
Per-element transform, can change type |
.compareElementsUsing(fn) |
Per-element equality check |
.subscribe(fn) |
Listen for changes |
.destroy() |
Tear down all subscriptions |
| Method | Description |
|---|---|
valueScope(fields) |
Define a scope template (fields only) |
valueScope(fields, ...derivations) |
Add up to 11 derivation layers, in dep order |
valueScope(fields, ...derivations, config) |
Plus an optional config layer (lifecycle hooks) |
scope.create(data) |
Create a single instance |
scope.createMap() |
Create an empty keyed collection |
scope.createMap(data, 'field') |
Create collection from array, keyed by field name |
scope.createMap(data, fn) |
Create collection from array, keyed by callback |
scope.createMap(map) |
Create collection from a Map or [key, data][] |
scope.extendValues(valuesOrDerivs) |
Derive a new scope (values-layer OR deriv-layer) |
scope.extendValues(values, ...derivations) |
Variadic layered extension (no config slot) |
scope.extendConfig(config) |
Attach lifecycle hooks; doesn't change definition |
| Method | Description |
|---|---|
.get() |
Read value |
.set(value) |
Write value (callback: prev => next). Values only. |
.use() |
React hook: [value, setter] or [value] for derivations |
.subscribe(fn) |
Per-field change listener: fn(value, previousValue) |
.flush() |
Expedite deferred work, await settle. Values and async derivations. |
.recompute() |
Re-run this derivation. Derived fields only. |
.useAsync() |
React hook: [value, AsyncState<T>]. Async derived fields only. |
.getAsync() |
Read AsyncState<T>. Async derived fields only. |
.useValidation() |
React hook: [value, setter, ValidationState<In, Out>]. Schema only. |
.getValidation() |
Read ValidationState<In, Out>. Schema fields only. |
| Method | Description |
|---|---|
.$get() |
Resolved values, scope refs stay live |
.$getSnapshot() |
Plain data, everything recursively resolved |
.$setSnapshot(d) |
Partial write, reactive fields only |
.$setSnapshot(d, { recreate }) |
Write + re-run onDestroy then onCreate |
.$use() |
React hook: [snapshot, setter], re-renders on any change |
.$subscribe(fn) |
Whole-scope change listener |
.$destroy() |
Tear down, fire onDestroy, detach all subscribers |
.$recompute() |
Re-run all derivations |
.$flush() |
Expedite pending deferred work, layer-ordered cascade |
.$getIsValid(opts?) |
True when all schema fields and validate pass |
.$useIsValid(opts?) |
React hook: re-renders when overall validity changes |
.$getValidation(opts?) |
{ isValid, issues } with scope-relative paths |
.$useValidation(opts?) |
React hook: re-renders when issue list changes |
| Method | Description |
|---|---|
.get(key) |
Get the instance for a key |
.set(key, data) |
Add or update an instance |
.delete(key) |
Remove and destroy an instance |
.keys() / .values() / .entries() |
List keys, instances, or both |
.has(key) |
Check if key exists |
.size |
Number of entries |
.useKeys() |
React hook: re-renders on add/remove |
.subscribe(fn) |
Listen for key-list changes |
.clear() |
Remove all, fires $destroy() for each |
| Option | Description |
|---|---|
onCreate |
{ scope, input, signal, onCleanup }. Once on create. |
beforeChange |
{ scope, changes, changesByScope, prevent }. Sync, pre-write. |
onChange |
{ scope, changes, changesByScope }. Batched, post-write. |
onUsed |
{ scope, signal, onCleanup }. First subscriber attaches. |
onUnused |
{ scope }. Last subscriber detaches. |
onDestroy |
{ scope }. Instance destroyed. |
validate |
{ scope }. Reactive derivation, returns StandardSchemaV1.Issue[]. |
| Property | Description |
|---|---|
scope |
Root scope. Access fields via scope.field.use() / .get(). |
signal |
(Async) AbortSignal, aborted on dep change or destroy. |
set(value) |
(Async) Push intermediate values |
onCleanup(fn) |
(Async) Register cleanup for re-run or destroy |
deferBy(ms) |
(Async) Abortable + flushable sleep |
previousValue |
(Async) The last resolved value, or undefined |
| Export | Description |
|---|---|
isValue(x) |
True if x is a reactive value field |
isSchema(x) |
True if x is a schema-validated value field |
isPlain(x) |
True if x is a non-reactive plain field |
isComputed(x) |
True if x is a derived value field |
isScope(x) |
True if x is a scope instance |
| Path | Contents |
|---|---|
valuse |
Core: value, valueScope, valueSet, valueMap, valueArray, types |
valuse/react |
React bridge: import 'valuse/react' to enable .use() hooks |
valuse/utils |
Pipe factories, async derivation helpers, and signal primitives |
valuse/middleware |
Shipped middleware: withDevtools, withPersistence, withHistory |