segment-state
v0.2.1
Published
Path-addressed state for Octane and React with O(observed) memory and transactional commits.
Maintainers
Readme
[!WARNING] Segment is experimental and currently follows
0.xversioning. Its core behavior is tested, but public APIs may still change before1.0.
What is Segment?
Segment is a small state engine for Octane and React applications where data has a natural address: records, documents, caches, server payloads, and large keyed collections.
Many state APIs make a selector, atom object, or store snapshot the identity at the call site. Segment instead gives every declared value a structural path:
users/42/profile/nameThat path can be read, written, observed, serialized, or passed to an external service without sharing an in-memory object reference. The same addressing model powers fine-grained subscriptions, transaction logs, server hydration, resources, and adapters.
| | What it means | | ------------------------ | ----------------------------------------------------------------------------- | | Structural paths | Any declared value can be reached through a typed ref or a plain path string. | | Targeted updates | A write wakes observers of the affected address, not the whole store. | | Atomic commits | Multiple writes land together; a thrown transaction is rolled back. | | O(observed) memory | Large segments materialize nodes only for addresses currently being watched. | | Async state built in | Resources support caching, cancellation, staleness, live data, and save-back. | | Runtime independent | The core imports neither a UI framework nor the DOM. |
Benchmark
The suite combines exact work counts with a 20,000-record workload, 2,000 targeted writes, and a 200-row mounted window. Callback, selector, and retained-entry counts are the primary results; elapsed time and heap measurements are machine-specific and should be read directionally.
Run it locally with pnpm benchmark. The full
benchmark methodology
documents the fixture, library versions, measurement caveats, and the commands used
to compare a candidate change against an identical baseline. The comparison
describes design trade-offs, not a universal ranking for every application shape.
Installation
npm install segment-state octane # an Octane application
npm install segment-state react # a React application (React 19+)pnpm add segment-state octane
pnpm add segment-state reactInstall the renderer you use: the package root serves the Octane hooks, and
segment-state/react serves the React ones. Both peers are optional, so neither
renderer is pulled into an application that uses the other. Segment is ESM-only,
and Node.js 22+ is required for Node runtimes and development tooling.
Renderer-free server, worker, or tooling modules can import the DOM-free engine
from segment-state/core.
Quick start
Declare the shape of the state once. Segment turns it into a typed tree of addresses while keeping values inside the store.
import { createStore, segment } from 'segment-state';
export const store = createStore({
todos: segment({ title: '', completed: false }),
});
export const s = store.state;
s.todos.replaceAll({
docs: { title: 'Ship the documentation', completed: false },
release: { title: 'Publish the package', completed: false },
});
const completed = s.todos.at('docs').completed;
const stop = store.observe(completed, () => {
console.log('completed:', store.get(completed));
});
store.update(completed, (value) => !value, 'todo/toggle');
console.log(completed.path); // todos/docs/completed
stop();There is no provider and no hidden global store:
createStore()creates one isolated state container.store.stateexposes typed refs; it does not expose mutable state objects.store.get()reads once, whilestore.observe()subscribes.- Every write becomes a named commit that adapters and external services can see.
Atomic updates
Use store.act() when several writes must become visible together:
store.act((tx) => {
tx.set(s.todos.at('docs').completed, true);
tx.set(s.todos.at('release').completed, true);
}, 'release/complete');Observers see one commit and never an intermediate state. If the callback throws, none of its writes are published.
Define the state model
Ordinary values are ordinary writable state. Markers are only needed when the value itself cannot describe the behavior you want.
| Declaration | Use it for |
| ------------------ | -------------------------------------------------------------------- |
| count: 0 | A writable value with an inferred type. |
| profile: { … } | A branch whose fields each receive an address. |
| cell<T>(initial) | A narrowed union or a plain object stored as one value. |
| segment({ … }) | A large keyed collection with observation-scaled memory. |
| list({ … }) | An addressable array whose items have addressable fields. |
| derived<T>() | A cached synchronous value computed from other addresses. |
| resource<T>() | Async state with load, save, cancellation, staleness, and live data. |
| action() | One synchronous, atomic state transition. |
| task() | An async flow made of several atomic transitions. |
Computed slots and callable actions receive their implementations through
.with(). A store containing only plain data does not need this step.
import { action, createStore, derived } from 'segment-state';
export const counter = createStore({
count: 0,
doubled: derived<number>(),
increment: action<[by?: number]>(),
}).with((s) => ({
doubled: (get) => get(s.count) * 2,
increment: (tx, by = 1) => tx.update(s.count, (count) => count + by),
}));
counter.state.increment(2);
console.log(counter.get(counter.state.doubled)); // 4.with() is optional
If you prefer an explicit module API, keep the schema data-only and export ordinary functions. Use the equivalent store-level composition methods for behavior that still needs a reactive address:
import { createStore } from 'segment-state';
interface User {
name: string;
}
export const app = createStore({ count: 0, updatedAt: 0 });
export const s = app.state;
export function increment(by = 1): void {
app.update(s.count, (count) => count + by, 'counter/increment');
}
export function reset(): void {
app.act((tx) => {
tx.set(s.count, 0);
tx.set(s.updatedAt, Date.now());
}, 'counter/reset');
}
export const doubled = app.derive((get) => get(s.count) * 2);
export const loadUser = app.resourceOf<User, [id: string]>(async ({ args: [id], signal }) => {
const response = await fetch(`/api/users/${id}`, { signal });
return (await response.json()) as User;
});Both styles use the same store and commit protocol. Choose .with() when behavior
should be discoverable on store.state, needs a stable schema address, or a task()
should expose automatic status, result, and error refs. Choose exported
functions for a smaller, conventional module API. A multi-write function should use
store.act() to remain atomic; a standalone resource created with resourceOf()
has a session-local address.
See the state model guide for collections, refs, derivations, actions, and tasks. Resources, SSR, ports, and persistence boundaries live in the advanced guide.
Read and write from anywhere
The core API is deliberately small:
| Operation | Purpose |
| ------------------------ | ------------------------------------------------------ |
| store.get(ref) | Read a value without subscribing. |
| store.observe(ref, cb) | Subscribe to one address or subtree. |
| store.set(ref, value) | Replace one writable value. |
| store.update(ref, fn) | Apply one read-modify-write transition. |
| store.patch(ref, data) | Update selected fields as one commit. |
| store.act(fn) | Group multiple reads and writes atomically. |
| store.ref(path) | Resolve an address from a structural path string. |
| store.commits(cb) | Subscribe to the serializable stream of state changes. |
This makes the same store usable from UI code, tests, workers, sockets, persistence layers, and developer tools.
Server rendering: server → client
Segment uses the same store model on the server and in the browser. Each server request creates an isolated store, renders from it, and sends only a versioned data payload to the client. The browser creates its own store and hydrates that payload before the first client render.
// app-state.ts — imported by both server and client
import { cell, createStore } from 'segment-state';
export interface Viewer {
id: string;
name: string;
}
export function createAppStore() {
return createStore({
page: {
title: '',
viewer: cell<Viewer | null>(null),
},
ui: { theme: cell<'light' | 'dark'>('light') },
});
}On the server, create a fresh instance for every request and embed a safely escaped payload next to the rendered application:
import { dehydrate } from 'segment-state/ssr';
const store = createAppStore();
store.patch(store.state.page, {
title: 'Dashboard',
viewer: await loadViewer(request),
});
const appHtml = await renderApp(store);
const payload = dehydrate(store, { at: Date.now() });
const payloadJson = JSON.stringify(payload).replaceAll('<', '\\u003c');
return `
<div id="app">${appHtml}</div>
<script id="segment-state" type="application/json">${payloadJson}</script>
<script type="module" src="/client.js"></script>
`;In the browser, hydrate before mounting so the first client render sees exactly the state used for the server HTML:
import { hydrate, type Payload } from 'segment-state/ssr';
const element = document.querySelector<HTMLScriptElement>('#segment-state');
const root = document.querySelector('#app');
if (!element?.textContent || !root) throw new Error('Incomplete SSR document');
const payload = JSON.parse(element.textContent) as Payload;
const store = createAppStore();
hydrate(store, payload, { maxAge: 60_000 });
mountApp(store, root);hydrate() publishes one atomic commit. Derived values and actions are recreated
from code, not serialized. A resource resolved on the server arrives ready on the
client and does not repeat its initial request; an older stamped value can still be
used for first paint and refreshed in the background through maxAge.
Never share a mutable module-level store between server requests. See the complete SSR and hydration guide for partial payloads, resource behavior, and authority boundaries.
Octane integration
The render-aware hooks are exported directly from segment-state. There is no
provider, and subscriptions stay scoped to the address read by a component.
import { useValue } from 'segment-state';
export function TodoRow({ id }: { id: string }) @{
const [completed, setCompleted] = useValue(s.todos.at(id).completed);
<button onClick={() => setCompleted(!completed)}>
{completed ? 'Done' : 'Mark complete'}
</button>
}useValue(ref)reads writable, derived, branch, and resource addresses.useStatus(ref)exposes resource state without suspending.useDraft(ref)keeps a local edit and publishes it on demand.
Store operations remain usable outside a component through get, observe, and
commit streams. Import those APIs from segment-state/core in infrastructure that
must not load a renderer.
React integration
The same three hooks ship for React (19 or newer) through segment-state/react,
which also re-exports the whole application API, so a React app imports from one
place and never loads Octane:
import { useValue } from 'segment-state/react';
export function TodoRow({ id }: { id: string }) {
const [completed, setCompleted] = useValue(s.todos.at(id).completed);
return (
<button onClick={() => setCompleted(!completed)}>{completed ? 'Done' : 'Mark complete'}</button>
);
}The contract matches the Octane binding: no provider, subscriptions scoped to the
address a component reads, resource reads waiting through <Suspense>, and the
array form of useValue starting several loads before suspending once.
Where Segment fits
Segment is a strong fit when:
- a large keyed dataset has a much smaller visible or observed window;
- state must be addressed outside the component that created it;
- several writes must be atomic and attributable;
- server payloads, workers, sockets, or devtools need one serializable protocol;
- async values should share the same address and lifecycle model as local state.
For a small amount of component-local UI state, the state primitive built into your renderer is usually simpler. Segment is also intentionally not a router, database, or request client—it coordinates application state around those systems.
Package entry points
| Import | Contents |
| --------------------- | ---------------------------------------------------------------- |
| segment-state | Store API, Octane hooks, and tree-shakeable adapter helpers. |
| segment-state/core | Renderer-free state engine; imports neither a renderer nor DOM. |
| segment-state/react | The same application API with React hooks in place of Octane's. |
| segment-state/ports | Optional path-based external adapter lifecycle. |
| segment-state/ssr | Optional dehydrate() and atomic hydrate() serialization API. |
Documentation
| Resource | What it covers | | ---------------------------------------------------------------------------- | ----------------------------------------------------------- | | Documentation site | Searchable guide and API concepts. | | Getting started | Installation, first store, subscriptions, and transactions. | | State model | Cells, branches, segments, lists, derivations, and actions. | | Advanced guide | Resources, Octane, SSR, hydration, ports, and guarantees. | | Playground | Embedded interactive store and a full-screen application. | | Core internals | Trie design, commit protocol, complexity, and measurements. | | Release guide | Maintainer release and trusted publishing workflow. |
Development
pnpm install
pnpm checkRun the interactive example with pnpm playground, or build it with
pnpm playground:build. The playground consumes the package through workspace:*;
pnpm pack:check additionally installs the real tarball in a clean offline consumer
to catch missing files or broken exports.
Agent guidance is maintained once in .rulesync/ and generated for Codex, Claude
Code, Cursor, and GitHub Copilot. After changing a rule or skill, run
pnpm agents:generate; pnpm check verifies that all committed agent files remain
in sync. The included skills cover architecture and API design, performance audits,
and systematic regression hunting.
Every ordinary pull request must include release intent. Use a semantic changeset for published API or runtime behavior:
pnpm changesetFor documentation, tests, examples, benchmarks, CI, dependency maintenance, or other non-release work, add an empty changeset instead:
pnpm changeset --emptyAfter changesets reach main, GitHub Actions maintains a reviewable version PR that
updates package.json and CHANGELOG.md. The generated changeset-release/* PR is
the only exception to the CI rule because it consumes those files. Merging that PR
automatically publishes the stable package under npm's latest dist-tag, then
creates the matching v<version> tag and GitHub Release. See the
release guide for version policy,
dry runs, recovery, and trusted npm publishing.
License
MIT © Michal Makowski
