Guides

Performance and bundle boundaries

Interpret Coaction benchmarks honestly and keep local applications on the transport-free entry point.

Performance claims are useful only when the scenario, runtime, state shape, and package versions are visible. Coaction keeps its benchmark scripts in the repository so results can be regenerated instead of treated as timeless numbers.

Choose the local entry point

For a vanilla local store:

import { create } from 'coaction';

This static boundary excludes shared protocol, epoch, reconnect, and transport modules from the entry. Use coaction/shared for the shared runtime, or @coaction/react/shared in React. The default React entry, @coaction/react, also stays local.

Bundle budgets in the repository measure published entries and tree-shaken consumer fixtures. They are regression gates, not a promise that an application bundle equals the budget number; the final result also depends on external dependencies retained by the application's bundler.

Run the maintained checks

pnpm build
pnpm package:size
pnpm benchmark
pnpm benchmark:zustand-positioning
pnpm benchmark:check
  • package:size checks published entry budgets and core entry isolation.
  • benchmark compares large update paths, including Coaction draft updates and Zustand + Immer.
  • benchmark:zustand-positioning separates stable derived reads from update-then-read work.
  • benchmark:check enforces regression floors with CI headroom.
  • benchmark:derived runs built-entry workloads in fresh processes: sparse paths, related/unrelated writes, full scans, cached reads, and unread/disposed owners. It is included in benchmark:check; full deep scans are reported to make their tracking cost visible, while native getters provide the coarse scan baseline.

Thresholds must not be lowered merely to make a failing run green. A threshold change needs a reviewed runtime-semantic or benchmark-methodology reason.

Understand the derived-state tradeoff

Cached accessors make repeated stable reads extremely cheap. After an update, Coaction still must preserve the readonly public-state boundary and update the snapshot used by computed traversal. That makes “update then read a derived value” a different path from a stable cached read.

Leave enablePatches unset unless an integration explicitly needs a journal. Commit hooks, shared transport and some tracking paths request patches automatically. Eligible small read sets can instead compare observed values before and after a write without generating patches; cached getter snapshots, patch middleware and larger dependency sets retain the patch path. Ordinary subscribe() listeners do not request patches themselves.

Do not publish one of those cases as a universal “faster than Zustand” claim. Zustand can make a derived read constant-time by storing the derived value, but application actions must then keep it consistent. Coaction's proposition is maintained cached derivation, not winning every mutation microbenchmark.

Render performance

Both useStore(selector) and observer() use managed deep path tracking on native immutable stores. A reader of state.user.name can stay cached after an age-only recipe update. Selectors still compare their results with Object.is; observer() collects reads during render without requiring a selector. Measure selector executions and render counts separately.

Native getters and get(deps, fn) retain the store/slice field boundary. A getter reading this.user.name can recompute when user.age changes, even if its result stays equal and its consumers do no extra work. Use derive(..., { deep: true }) or derivePath when a managed derived read needs finer dependencies.

Parent replacement, array structure changes and graph snapshot patches can invalidate more broadly. External adapters follow their own update and snapshot contracts. See computed tracking granularity for the boundaries.

Practical checklist

  • Import coaction for vanilla local-only state.
  • Leave enablePatches unset unless needed; account for consumers that request patches automatically.
  • Use cached getters for repeated derived reads, not side-effectful work.
  • Use observer() where it removes real selector composition.
  • Profile production builds and representative state, not development mode alone.
  • Treat benchmark floors as regression detection and rerun publishable numbers on the target machine.

On this page