active-state
Framework-independent state for client logic with in-flight work - stores, derived values, effects, operations and lifetimes, with a React adapter.
@workspace/active-state 1.0.1Developmentunreleased, in development in Foundationbaseline ce081dc3
@workspace/active-state keeps client state outside your components. The core has six nouns:
Store, Derived, Effect, Operation, Scope and the transaction (transaction()), plus
batch and shallow. A React adapter (useStore) and a testing entry sit on top. There is no
provider: a model is plain objects you create where you need them.
A store, derived values and useStoreClient
- Notebook: 4.50
1 item, total 4.50
// apps/docs/examples/state/cart-total.tsx'use client';
import {Derived, shallow, Store, useStore} from '@workspace/active-state/react';import {Button} from '@workspace/active-ui/components/primitives/button';
interface Line { product: string; price: number;}
// Module scope: the model lives outside React and needs no provider.const lines = new Store<Line[]>([{product: 'Notebook', price: 4.5}], { equal: shallow,});const total = new Derived({ inputs: [lines], compute: (list) => list.reduce((sum, line) => sum + line.price, 0),});const count = new Derived({inputs: [lines], compute: (list) => list.length});
const PRODUCTS: Line[] = [ {product: 'Pen', price: 1.25}, {product: 'Folder', price: 2}, {product: 'Stapler', price: 7.5},];
/** A Store, two Derived values over it, and useStore in React. */export default function CartTotal() { const items = useStore(lines); const sum = useStore(total); const n = useStore(count); return ( <div className="flex flex-col gap-4"> <div className="flex flex-wrap gap-2"> {PRODUCTS.map((line) => ( <Button key={line.product} variant="outline" size="sm" onClick={() => lines.update((list) => [...list, line])} > Add {line.product} </Button> ))} <Button variant="ghost" size="sm" onClick={() => lines.setState([])} disabled={n === 0} > Empty </Button> </div> <ul className="text-muted-foreground text-sm"> {items.map((line, index) => ( <li key={index}> {line.product}: {line.price.toFixed(2)} </li> ))} </ul> <p className="text-sm" aria-live="polite" data-cart-total> {n} item{n === 1 ? '' : 's'}, total {sum.toFixed(2)} </p> </div> );}What it gives you#
- Simple state stays simple. A
Storeis a value withsetState,updateandsubscribe. You need none of the other nouns to use it. - Derived values with explicit inputs. A
Deriveddeclares its inputs and computes from their values. A read is always current, observed or not. - Settle, then publish. Subscribers receive
(value, previous)after the whole affected graph has settled, once per round, so nobody sees a new input next to a stale derived value. - Two boundaries.
batchgroups notifications and never reverts anything;transactionis synchronous and all-or-nothing, and a reverted transaction notifies nobody. - Owned async work. An
Operationhas a policy (latest-wins,drop-while-running,queue), typed outcomes that never throw, and cancellation. It changes state only throughctx.commit, so a superseded or cancelled call cannot overwrite newer state. - Explicit lifetime. A
Scopeowns stores, effects, operations and child scopes and disposes them in a fixed order. - React as an adapter.
useStoreisuseSyncExternalStorewith a selection andshallowcomparison by default. - Testable without React.
@workspace/active-state/testingcaptures errors, records events and drives operations step by step.
What it does not do#
- No default equality on a store. A
Storewithoutequalpublishes every write, also an identical value. Passequal: shallow(or your own) where you rely on value semantics. - No persistence, no network, no serialization. Fetching, storage and hydration data are
yours; an
Operationonly owns the life of the work. - No React context.
useStorereads the object you pass; it never reads a provider. An error in a subscriber or effect goes to the runtime's error sink (reportError), not to a React error boundary.
Entry points#
| Import | Contents |
|---|---|
@workspace/ | the framework-independent core; imports no React |
@workspace/ | the whole core again (the same objects), plus use and use |
@workspace/ | test seams and the instrumentation protocol; internal and unstable, for tests |
Pick one of the first two per file: /react repeats the whole core.
What your application provides#
- React 19 and
@types/react, only where you import/react. The core needs nothing. - A TypeScript-aware build: the package ships its TypeScript source.
- The data and the side effects: requests, storage, timers. Put them in an
Operationor anEffectso their lifetime is owned.
Dependencies#
None at run time. react is an optional peer, needed only for /react.
Next#
- Getting started: receive the package and build a first model.
- Async work: requests that cannot overwrite newer state.