Valdres
Valdres is a synchronous atom and selector state engine for JavaScript, with
React 18 and 19 bindings. [email protected] and
[email protected] are the first public builds of the new module-owned
runtime, scoped stores, atomic transactions, and lifecycle-free subscriptions.
The website currently documents the legacy beta. The install path and examples
below are the source of truth for the v1 beta while the site is being migrated.
npm install valdres@beta valdres-react@beta react
import { atom, selector, store } from "valdres"
import { Provider, useUpdateAtom, useValue } from "valdres-react"
const countAtom = atom(0)
const doubledSelector = selector(get => get(countAtom) * 2)
const appStore = store()
function Counter() {
const count = useValue(countAtom)
const doubled = useValue(doubledSelector)
const increment = useUpdateAtom(countAtom)
return (
<button onClick={() => increment(c => c + 1)}>
{count} ×2 = {doubled}
</button>
)
}
const App = () => (
<Provider store={appStore}>
<Counter />
</Provider>
)
Atoms and selectors are immutable capability handles identified by reference. A
Store owns their values and derived graph. set stores an exact value,
including functions; update applies an updater. Stores also provide
synchronous subscriptions, transactions, nested scopes, reset, and explicit
disposal.
External sources
externalAtom(source, { name? }) projects synchronous externally owned truth as
read-only State. A Store shares one source subscription across retained scopes
and selector consumers, captures each source once per transaction, and releases
it when the last consumer leaves. Snapshot comparison uses Object.is.
import { externalAtom, store } from "valdres"
const fixedSource = externalAtom({
getSnapshot: () => 42,
getServerSnapshot: () => 42,
subscribe: () => () => {},
}, { name: "external answer" })
const app = store()
app.get(fixedSource) // 42
app.dispose()
Every constructor call creates a fresh definition. Keep definitions stable and
provide a synchronous cleanup from subscribe. React SSR/hydration uses the
server snapshot without attaching a live listener. Read the
ExternalAtom reference and
integration guide for updates, lifecycle, errors,
and request-local hydration examples.
Parameterized State identity
family(factory) memoizes one Atom, Selector, or ExternalAtom per non-empty ordered tuple of
primitive keys (string, number, bigint, boolean, symbol, null, or
undefined). Keys use SameValueZero, so NaN matches NaN and 0 matches
-0; tuple arity and order still matter.
import { atom, family } from "valdres"
const cellValue = family((sheetId: string, row: number) => atom(""))
cellValue("budget", 4) === cellValue("budget", 4) // true
type Step = { readonly id: string; readonly title: string }
const stepProgress = family((step: Step) => atom(0), {
encodeKey: step => step.id,
})
stepProgress({ id: "step-1", title: "Draft" }) ===
stepProgress({ id: "step-1", title: "Review" }) // true
Structured arguments require a synchronous encodeKey that returns one
canonical primitive key. The factory must construct and return its Atom,
Selector, or ExternalAtom during that member's construction, or return any member already
published by a family. Returning an arbitrary pre-existing State is rejected.
The exported FamilyKey type names the primitive-key union for reusable APIs.
Factories and encoders are synchronous definition callbacks, not Store work.
Neither may perform Store, scope, transaction, or subscription operations or
borrow an active selector's get. Encoders also cannot construct State
definitions or call a family; factories may do both. A violation throws without
caching a member.
The family cache is weak. A live reference, a committed Store override for a
family Atom, an active subscription, or a retained selector dependency keeps
that member alive. Reset or disposal releases Store ownership, and
unsubscription releases subscription ownership. After the last owner disappears,
a later lookup may run the factory again.
A family is only a callable identity cache. It has no membership or enumeration
API and no delete, release, index, Store, or collection surface.
Keyed collections
collection() defines canonical keyed row handles and is itself a readonly
State containing the rows currently present in a Store. presence(row) returns
a readonly Selector for one row's presence:
import { collection, presence, store } from "valdres"
type Session = { id: string; active: boolean }
const sessions = collection<string, Session>()
const session = sessions("session-1")
const app = store()
app.set(session, { id: session.key, active: true })
app.get(session) // { id: "session-1", active: true }
app.get(sessions) // [session]
app.get(presence(session)) // true
app.txn(transaction => {
transaction.update(session, current => ({ ...current, active: false }))
transaction.delete(session)
})
Looking up or reading a row does not insert it. Store and Transaction operations
support row set, update, reset, and delete; row values must be synchronous
and cannot be undefined. Membership is a frozen ordered snapshot based on
continuous absent-to-present transitions, so value-only updates preserve its
identity. Child scopes inherit rows, a local delete hides a parent row, and
reset reconnects inheritance.
Opt-in structural equality
Object.is remains the default comparator. Pass deepEqual only where
structurally equal replacements should retain the previous reference and stop
notifications or downstream propagation:
import { atom, deepEqual } from "valdres"
const documentAtom = atom({ blocks: [] }, { equal: deepEqual })
deepEqual recursively compares supported values using SameValueZero primitive
leaves, own enumerable string and symbol properties, and native identity for Map
keys and Set members. Non-binary objects require the same prototype. Matching
binary brands compare visible bytes across realms and ignore attached
properties. Functions, Promises, Errors, URLs, weak collections, other opaque
platform objects, and DOM nodes compare only by identity. Cyclic structures are
unsupported. Reached getters, Proxy traps, valueOf, and toString hooks can
run and throw.
Structural comparison walks the compared values, so use it deliberately on
allocation-heavy results where pruning redundant updates outweighs that work.
Bundles that never import deepEqual drop it.
Inspect a Store
valdres/inspect creates an opt-in Store with a bounded structural flight
recorder. It belongs to the same runtime domain as ordinary Stores, so existing
atoms, selectors, scopes, and framework adapters work unchanged:
import { createInspectableStore } from "valdres/inspect"
const { store: appStore, inspect } = createInspectableStore()
inspect.span("drop interaction", () =>
appStore.txn(transaction => {
transaction.update(processAtom, updateProcess)
}, "collapse process"),
)
const report = inspect.export()
inspect.reset()
The report links labels to opaque operation, commit, evaluation, session, and
search IDs. It records selector topology/search work, collection row and
membership work, external projection/lifecycle work, related counters, and
propagation/notification totals.
Summary/detail rings are bounded with explicit overflow. Exports are immutable
and JSON-safe; collection keys, values, callbacks, errors, and live State handles
are never recorded. Labels are metadata, not identity. The core inspection
schema is version 7.
Inspection adds recording and timing work only to the Store created by
createInspectableStore. The recorder stays outside the ordinary root entry and
ordinary consumer bundles remain within their existing size budget.
store.txn(callback, name?) accepts the optional label on every Store; ordinary
Stores validate and otherwise ignore it.
Correlate React work
valdres-react/inspect binds the React adapter to an inspectable core. Use the
returned Provider and hooks only for the tree being measured:
import { createInspectableStore } from "valdres/inspect"
import { createInspectableReact } from "valdres-react/inspect"
const core = createInspectableStore()
const react = createInspectableReact(core)
const editorStore = core.store.scope("editor")
function Editor() {
const document = react.useValue(documentAtom)
// render document
}
const app = (
<react.Provider store={editorStore}>
<Editor />
</react.Provider>
)
const report = react.inspect.export()
react.inspect.reset()
Omit the Provider's store prop to use core.store, or pass a child scope from
the same inspector as shown above. Explicit Store arguments on the returned
hooks are also supported and must belong to that inspector.
The immutable composite export contains the exact core flight-recorder report
plus separately bounded React timelines. Subscriber rows retain the genuinely
active core IDs read through the recording-neutral
core.inspect.capture(store, state?) seam. Snapshot rows distinguish React's
synchronous subscriber check from later reads; they are reads, not component
render counts. Profiler rows are boundary callbacks on the same clock, while
their commitTimeGroupId is only a timestamp grouping key—not a unique commit
or a causal link to a Store operation.
Subscriber and snapshot timelines work in ordinary production React builds.
Profiler timing is present only in development or a profiling-enabled production
build. The recording/export retains no State values, props, children, callbacks,
errors, or component instances. This opt-in entry is absent from the ordinary
valdres-react root bundle and adds no capture work to ordinary hooks.
This is an intentional breaking beta cutover. Only valdres and valdres-react
are certified together. The Angular, Vue, Svelte, Solid, plugin, and
compatibility packages listed below remain on the legacy API and are unsupported
with [email protected] or later until they are migrated. Their published
semver ranges may still allow npm to install this core, so do not mix those
packages with the new beta.
Packages
The package tables below are auto-generated — do not hand-edit.
Core
| Package | Description |
|---|
valdres | Fast atom-based state management for JavaScript. Inspired by Recoil and Jotai. |
Framework adapters
Plugins (framework-agnostic)
React extras
Development
bun install
bun test # certified v1 core + React cohort
bun run test:all # deferred legacy maintenance lane
bun run docs:dev # legacy docs site at localhost:4321
Releasing
Versioning and publishing is handled by
Changesets. Each package versions
independently.
The future v1 stable release is governed by the hard gates and RC burn-in
checklist in RELEASING.md.
When you open a PR that changes a publishable package:
bunx changeset
Pick the affected packages, the bump type (patch/minor/major), and write a short
summary. Commit the generated .changeset/*.md file with your PR.
For PRs that touch publishable code but intentionally don't trigger a release
(refactors, internal cleanup, docs):
bunx changeset --empty
This still generates a .changeset/*.md file — commit it like a regular
changeset. The Require changeset check on ordinary feature PRs enforces that
any change to a publishable package ships with a changeset (empty or otherwise);
the generated Version Packages PR has already consumed those changesets.
When the PR merges to main, the Publish workflow opens (or updates) a
Version Packages PR that applies the pending changesets, bumps versions, and
updates CHANGELOGs. Merging that PR publishes the affected packages to npm.
To preview what publishing would do locally:
bun run verify-publish
The repo is currently in beta prerelease mode (bunx changeset pre exit to
graduate to stable). While in prerelease mode, changesets that have already been
versioned into a beta release move to .changeset/pre/. Certified core+React
histories are consumed by their stable release; ignored deferred histories
remain for their packages. Leave those files alone unless a change genuinely no
longer applies to its eventual release.
Benchmarks
Performance
The table below is the archived legacy-engine benchmark and does not describe
the 1.0.0-beta.24 runtime. New v1 numbers will replace it after the beta runs
in real applications, including ShiftX.
valdres is benchmarked against Jotai (and a
raw Map floor) on every PR via Bencher — live,
always-current latency per operation under both Bun (JavaScriptCore) and Node.js
(V8):
→ bencher.dev/perf/valdres
Live plot — store.get(atom) latency, valdres vs Jotai vs a raw Map
floor, on both runtimes. Auto-updates from main; click through to filter/zoom.
(Sparse until main accumulates a few runs.)
Every PR from the repo gets a comment flagging any latency regression vs main
(fork PRs are skipped — they can't read the upload key).
Performance vs Jotai
Latest main latency per operation (live, always-current numbers:
bencher.dev/perf/valdres). Auto-generated
from Bencher — do not hand-edit.
Bun (JavaScriptCore)
| Operation | valdres | Jotai | |
|---|
atom lifecycle (create+100get+100set) | 10.0µs | 97.6µs | 🟢 9.7× faster |
atom(1) | 3ns | 47ns | 🟢 16.1× faster |
atomFamily: direct create + delete 500 members | 591.9µs | 586.6µs | 🔴 1.0× slower |
atomFamily: direct set 500 new members | 410.1µs | 441.2µs | 🟢 1.1× faster |
atomFamily: txn update 5,000 existing members | 1.34ms | 4.90ms | 🟢 3.6× faster |
atomFamily(id) | 173ns | 296ns | 🟢 1.7× faster |
atomFamily(id) cache hit | 13ns | 7ns | 🔴 2.0× slower |
atomFamily(string) cache hit | 20ns | 16ns | 🔴 1.3× slower |
create + dispose 1,000 root stores | 365.8µs | 4.50ms | 🟢 12.3× faster |
createStore | 224ns | 4.7µs | 🟢 21.0× faster |
get 1000 atoms | 9.0µs | 155.2µs | 🟢 17.2× faster |
selector(fn) | 7ns | 48ns | 🟢 6.6× faster |
|
Node.js (V8)
| Operation | valdres | Jotai | |
|---|
atom lifecycle (create+100get+100set) | 27.4µs | 92.1µs | 🟢 3.4× faster |
atom(1) | 20ns | 43ns | 🟢 2.1× faster |
atomFamily: direct create + delete 500 members | 3.59ms | 2.08ms | 🔴 1.7× slower |
atomFamily: direct set 500 new members | 2.63ms | 1.31ms | 🔴 2.0× slower |
atomFamily: txn update 5,000 existing members | 5.86ms | 10.03ms | 🟢 1.7× faster |
atomFamily(id) | 139ns | 311ns | 🟢 2.2× faster |
atomFamily(id) cache hit | 94ns | 28ns | 🔴 3.4× slower |
atomFamily(string) cache hit | 118ns | 11ns | 🔴 10.5× slower |
create + dispose 1,000 root stores | 1.27ms | 632.6µs | 🔴 2.0× slower |
createStore | 798ns | 621ns | 🔴 1.3× slower |
get 1000 atoms | 16.0µs | 117.9µs | 🟢 7.4× faster |
selector(fn) | 37ns | 51ns | 🟢 1.4× faster |
|