Computed state
Choose frozen getter snapshots, explicit paths, or managed deep selectors.
Coaction's computed values use the same signal graph as field tracking. A component can read a cached getter, that getter can read store fields, and invalidation flows through the graph without manual memo arrays.
Accessor getters
Use a normal getter for the default derived-state experience:
type Item = { price: number; quantity: number };
const cart = create((set) => ({
items: [] as Item[],
taxRate: 0.08,
get subtotal() {
return this.items.reduce(
(sum, item) => sum + item.price * item.quantity,
0
);
},
get total() {
return this.subtotal * (1 + this.taxRate);
},
add(item: Item) {
set(() => {
this.items.push(item);
});
}
}));subtotal is cached until items changes. total depends on the cached subtotal and
taxRate; those relationships are discovered automatically.
Keep getters pure. A getter should not call setState(), mutate external state, or produce a
different result from hidden side effects.
Explicit dependencies with get()
The second argument passed to a store factory can declare dependencies manually:
const cart = create((set, get) => ({
items: [] as Item[],
total: get(
(state) => [state.items] as const,
(items) => items.reduce((sum, item) => sum + item.price * item.quantity, 0)
)
}));Use this form when dependency declaration makes cross-slice logic clearer, or when integration code should not rely on implicit getter reads.
Calling get() without arguments returns the current root state. In a slice factory this is a
convenient way to read another namespace, though update logic should still happen through
set().
Tracking granularity
Native getters and get(deps, fn) read frozen snapshots at the store/slice field boundary.
Reading this.user.name in a getter therefore invalidates that getter when user.age changes.
With get(s => [s.user.name], fn), the dependency selector runs again, but fn can reuse its
result when the selected name is unchanged. Recomputing a getter and notifying its consumers
are separate: an unchanged primitive result can suppress downstream work.
React selectors and observer use managed deep path tracking. A selector reading
state.user.name can ignore an age-only recipe update. Parent replacement, array structure,
and graph snapshot patches can invalidate more broadly.
Managed derived reads
The optional coaction/derived entry adds store-owned cached reads without changing native
getter defaults or adding this runtime to the default entry's consumer bundle:
import { create } from 'coaction';
import { derive, derivePath } from 'coaction/derived';
const person = create({ user: { name: 'Ada', age: 30 } });
// An exact state-data path: no intermediate object dependency collection.
const name = derivePath(person, ['user', 'name']);
// Dynamic value selection with leaf and structure dependencies.
const greeting = derive(person, (s) => `Hello, ${s.user.name}`, {
deep: true
});
name(); // 'Ada'
greeting(); // 'Hello, Ada'
person.setState((s) => {
s.user.age++;
});
greeting(); // cached: its selected name did not change
name.dispose();
greeting.dispose();
person.destroy();derivePath copies the path when created, supports string/number/symbol keys, and returns
undefined for missing data. It selects data, not methods or native computed getters. Use
derive to compose getters or to select a path dynamically. There is no incremental reduce,
filter, or sort algorithm hidden behind either API.
Object identity is a separate dependency
derive(store, selector) conservatively retains traversed object identities by default.
{ deep: true } opts into value selection: traversing an object to reach a child does not
automatically retain the parent's identity. JavaScript ===, Object.is, and WeakMap lookups
cannot be detected by property traps. Mark these uses explicitly:
import { derive, identity } from 'coaction/derived';
const captured = person.getState().user;
const description = derive(
person,
(s) => {
const user = identity(s.user);
return `${user === captured}:${user.name}`;
},
{ deep: true }
);An age change must rerun this selector because it replaces the user object, even though the
name is unchanged. identity() returns the same readonly object; whole() instead returns
owned raw data for coarse scans, which must never be mutated.
Returning a state object directly, or carrying it inside ordinary object/array data wrappers,
retains its terminal dependency automatically. Cyclic wrappers are supported. Output accessors
are not invoked, and opaque instances, functions and closures are not traversed: mark any state
objects captured there with identity() during selection. Arrays, holes, symbols, null
prototypes, cycles and shared references retain the local-state contract. Date, Map, Set and
class instances remain atomic identity leaves; their internal mutations are not made reactive.
Result equality
Result comparison defaults to Object.is, including its NaN and signed-zero behavior. A fresh
object result is different even when its fields are equal. Supply equals to reuse equivalent
derived data:
const adult = derive(person, (s) => ({ adult: s.user.age >= 18 }), {
deep: true,
equals: (before, after) => before.adult === after.adult
});Comparison is independent of dependency selection and runs without collecting dependencies. Even when a result is reused, branch dependencies are reconciled. The comparator must be pure. Changes to state objects carried in the output still propagate, including live root/slice facades: result equality does not hide a change to their state. Structural equality of arbitrary local graphs is not the default.
Transactions, ownership and React
Reads inside a recipe evaluate against that store's current draft without installing a cache
or retaining draft proxies. Failed recipes and validator vetoes leave the committed cache intact;
consumers are invalidated after an accepted commit. Selector failures remain failures on cached
reads and retry when a dependency changes. Compose derivations owned by the same store; this API
does not coordinate transactions between different stores. Selectors must be synchronous pure
reads. Work after await is outside dependency collection; this is not a Resource/Suspense API.
Each read keeps its current dependencies until dispose() or store.destroy(). Unread
derivations do not activate patches. Disposing is idempotent, releases the read's dependencies
and cache, and makes subsequent calls throw. Stopping a consumer does not dispose a store-owned
derivation: it stays available for other consumers. Native getter caches have their own existing
store lifetime.
Create derivations with their store or another explicit owner, outside React render. Creating
one in a render or relying on useMemo as a cleanup boundary can retain abandoned instances.
An existing store-owned read works in a selector or an observer:
const displayName = derive(usePerson, (s) => s.user.name, { deep: true });
function Name() {
return <span>{usePerson(() => displayName())}</span>;
}Here usePerson is a store from @coaction/react. Dispose displayName when its owner ends,
or destroy that store. Unmounting a component releases its React subscription. SSR stores and
their derived reads belong to the request; shared clients derive from their accepted mirror
state. Derived definitions and caches never enter patches, history or transport payloads.
Standalone signal primitives
computed, signal and effect exported by coaction remain alien-signals primitives. They
are useful for independent signal graphs, but do not provide store-owned disposal or draft-aware
caching. Standalone computations retain conservative object dependencies. A whole root-facade
result needs an explicit whole(store.getState()) read to register an aggregate dependency.
Their Coaction path bookkeeping lasts for the store lifetime; prefer derive for dynamic
store selectors that need dependency reclamation. Plain getters nested inside initial data are
still materialized as data, not recursively registered computed definitions.
External adapters
Native accessor/getter computed behavior belongs to Coaction-owned state. External
adapter instances do not gain Coaction's cached getters or get(deps, selector) semantics.
Their own runtime—MobX computed values, Pinia getters, selectors, or machine state—remains
responsible for derived state. derive and derivePath explicitly reject external adapters.
Performance expectations
Stable repeated reads are the strongest path for cached getters. Updates still preserve the readonly public-state boundary and must refresh the cached snapshot before the next derived read. Treat microbenchmarks as regression signals, not universal claims; see the performance guide for the maintained commands and interpretation.
Deep tracking is suited to sparse reads. For full collection scans, keep a native getter over a
frozen snapshot; derive(store, s => s.total) can compose that getter. Reading every item through
a deep proxy adds tracking work without avoiding any evaluation. Immutable updates still copy
changed ancestor containers, so a flat-array scalar write is not O(depth) independent of size.
pnpm benchmark:derived reports sparse workloads at 1k/10k/50k scales, full scans at 10k,
cached reads, and writes with unread/disposed derivations. It is included in benchmark:check.