A Zustand-style store where render tracking and cached computed state are built in.
No selectors. No useShallow. No useMemo. Just read state and it stays fast.
Quick look · Why Coaction · Install · Examples · Docs
Quick look
The same counter — but no selector, no useShallow, and derived state that caches itself:
import { create, observer } from '@coaction/react'; const useCounter = create((set) => ({ count: 0, step: 1, // cached automatically — recomputed only when `count` changes get doubled() { return this.count * 2; }, increment() { set(() => { this.count += this.step; // mutable write, immutable result }); } })); const Counter = observer(() => { const store = useCounter(); // tracks only the fields it actually reads return ( <button onClick={store.increment}> {store.count} (step {store.step}) → {store.doubled} </button> ); });
The same thing in Zustand — selector + shallow equality + manual memo
import { create } from 'zustand'; import { useShallow } from 'zustand/react/shallow'; import { useMemo } from 'react'; const useCounter = create((set) => ({ count: 0, step: 1, increment: () => set((s) => ({ count: s.count + s.step })) })); function Counter() { const { count, step } = useCounter( useShallow((s) => ({ count: s.count, step: s.step })) ); const doubled = useMemo(() => count * 2, [count]); return ( <button onClick={() => useCounter.getState().increment()}> {count} (step {step}) → {doubled} </button> ); }
Why Coaction
You don't need a worker, tabs, or CRDTs to benefit. Coaction folds the pieces you'd normally assemble by hand into one cohesive signal graph, so tracking, computed values, and the fields they read invalidate together:
- Automatic render tracking —
observer()re-renders a component only for the fields it reads. No selectors, nouseShallow. - Cached computed by default —
get value()getters memoize until a dependency changes. NouseMemo, no reselect. - Mutable writes, immutable results — just
this.count += 1insideset(). Powered by Mutative (~18x faster than Zustand + Immer in our benchmark). this+ OOP-style actions — natural getters and actions; methods destructured fromgetState()stay bound.- Escape hatches when you want them —
useStore(selector),useStore.auto(), andget(deps, selector)keep explicit control available.
And when you need it, the same store scales up. Built on a transport + patch foundation, the same store source can run in a Worker, SharedWorker, across tabs, or in real-time collaboration — multithreading is the ceiling, not the entry fee. Adopt the single-threaded DX first, grow into shared mode when the architecture calls for it.
Install
For the core library without any framework:
For React applications:
npm install coaction @coaction/react
Works with React, Vue, Angular, Svelte, and Solid, plus adapters for Redux, Zustand, MobX, Pinia, Jotai, Valtio, and XState. See Integration for package names and docs.
Coaction or Zustand?
Coaction keeps a familiar Zustand-style create API but chooses a larger, batteries-included
runtime. Zustand is the smaller, more battle-tested choice when selectors and middleware
already cover the problem cleanly.
Reach for Coaction when:
- components are selector-heavy or lean on repeated derived state
- you want derived values cached by default, without
useMemo/reselect - you'd otherwise stack
react-tracked+ a computed plugin + auto-selectors and maintain it yourself - Worker / multi-tab / collaboration is on your roadmap
Stick with Zustand when:
- you need a small hook store with a few selectors
- a near-zero-dependency core and bundle minimalism are top priorities
- your team prefers explicit, magic-free subscriptions
See the honest, detailed case in Why Coaction Without Multithreading and the full Coaction vs Zustand comparison.
Usage
Your first store
import { create, observer } from '@coaction/react'; const useStore = create((set) => ({ count: 0, get doubleCount() { return this.count * 2; // cached until `count` changes }, increment() { set(() => { this.count += 1; }); } })); const Counter = observer(() => { const store = useStore(); return ( <div> <p>Count: {store.count}</p> <p>Double: {store.doubleCount}</p> <button onClick={store.increment}>Increment</button> </div> ); });
Wrap a component in observer() and it subscribes to exactly the fields it reads. Plain
useStore() outside observer() stays a whole-store subscription — use useStore(selector)
when you want the classic explicit style.
Writes happen inside set()
Coaction state is immutable by default. Getters and methods read through this, but writes
must go through set():
incrementWrong() { this.count += 1; // ❌ throws — outside set() } increment() { set(() => { this.count += 1; // ✅ mutable draft, immutable result }); }
set() is the boundary where Coaction produces the next immutable state and notifies
subscribers. When patches are enabled, it's also where patch pairs are generated — the same
mechanism that powers shared mode later.
Derived state
Accessor getters are the default derived-state API and cache automatically:
import { create } from '@coaction/react'; type CartItem = { price: number; quantity: number }; const useCart = create((set) => ({ items: [] as CartItem[], get total() { return this.items.reduce((sum, i) => sum + i.price * i.quantity, 0); }, add(item: CartItem) { set(() => { this.items.push(item); }); } }));
When you want explicit dependencies, use the get(deps, selector) form:
import { create } from '@coaction/react'; type CartItem = { price: number; quantity: number }; const useCart = create((set, get) => ({ items: [] as CartItem[], total: get( (state) => [state.items], (items) => items.reduce((sum, i) => sum + i.price * i.quantity, 0) ) }));
Escape hatches
Automatic tracking is the default, not a cage. The full explicit toolbox stays available:
import { createSelector } from '@coaction/react'; // selector across multiple stores; returns a hook const useCartCredit = createSelector(useCart, useUser); const selectors = useCart.auto(); function CartSummary() { // classic selector (familiar Zustand DX) const total = useCart((state) => state.total); // cached auto-selector map const total2 = useCart(selectors.total); // selector across multiple stores const remaining = useCartCredit((cart, user) => cart.total + user.credit); return <span>{total + total2 + remaining}</span>; }
The explicit
useStore(selector)path is version + recompute +Object.is— the same model Zustand uses. Coaction's fine-grained tracking lives inobserver()and cached getters, so mix the styles freely.
Slices
Slices are a first-class store shape with namespace support:
const counter = (set) => ({ count: 0, increment() { set(() => { this.count += 1; // `this` targets the slice }); }, incrementByStep() { set((draft) => { draft.counter.count += draft.settings.step; // root draft for cross-slice }); } }); const settings = (set) => ({ step: 1, setStep(step) { set(() => { this.step = step; }); } }); const useStore = create({ counter, settings }, { sliceMode: 'slices' });
Methods destructured from getState() stay bound:
const { increment } = useStore.getState().counter; increment(); // still works — `this` stays bound to the slice
Scaling up: shared mode
Everything above runs single-threaded. When your architecture calls for it, the same store source can move to a Worker, SharedWorker, or multiple tabs — no rewrite, no manual message passing.
counter.js
export const counter = (set) => ({ count: 0, increment() { set(() => { this.count += 1; }); } });
worker.js
import { create } from '@coaction/react'; import { counter } from './counter'; create(counter);
App.jsx
import { create } from '@coaction/react'; import { counter } from './counter'; const worker = new Worker(new URL('./worker.js', import.meta.url), { type: 'module' }); const useStore = create(counter, { worker });
In shared mode the worker owns the state (the main store); webpage threads are client mirrors that read local state and proxy method calls to the main store. Coaction handles sequencing, patch sync, and reconnect recovery for you.
TypeScript note: in a client context the store type is
AsyncStore(methods become async, proxied to the worker); in the worker context it's a synchronousStore.
See the threading model for the full authority rules.
Reusable SharedWorker store
For multi-tab state, the same store module can create a SharedWorker on the webpage and run
as the authority store inside the worker:
import { create } from 'coaction'; const worker = globalThis.SharedWorker ? new SharedWorker(new URL('./store.js', import.meta.url), { type: 'module' }) : undefined; export const store = create( (set) => ({ count: 0, increment() { set(() => { this.count += 1; }); } }), worker ? { worker } : undefined );
See the reusable store example and the 3D multi-window scene for SharedWorker patterns.
Performance
Benchmark updating 50K arrays and 1K objects, higher is better (source):
Benchmark snapshot from the current
scripts/benchmark.tscomparison.
| Library | ops/sec | Relative |
|---|---|---|
| Coaction | 5,272 | 1.0x |
| Coaction with Mutative | 4,626 | 0.88x |
| Zustand | 5,233 | 0.99x |
| Zustand with Immer | 253 | 0.05x |
Coaction performs on par with Zustand in standard usage. The gap appears with immutable helpers: Coaction with Mutative is ~18.3x faster than Zustand with Immer.
For the benchmark methodology and derived-state positioning, see Zustand-focused benchmarks.
Integration
Coaction works across frameworks, with adapters for popular state libraries and middleware.
| Framework | Package |
|---|---|
| React | @coaction/react |
| Vue | @coaction/vue |
| Angular | @coaction/ng |
| Svelte | @coaction/svelte |
| Solid | @coaction/solid |
| State library | Package |
|---|---|
| MobX | @coaction/mobx |
| Pinia | @coaction/pinia |
| Zustand | @coaction/zustand |
| Redux Toolkit | @coaction/redux |
| Jotai | @coaction/jotai |
| XState | @coaction/xstate |
| Valtio | @coaction/valtio |
| Middleware | Package |
|---|---|
| Logger | @coaction/logger |
| Persist | @coaction/persist |
| Undo/Redo | @coaction/history |
For collaboration, see @coaction/yjs.
Support boundaries are documented, not implied. Slices mode is core-only; third-party state adapters bind the whole store. Not every feature works in every mode — see the support matrix for the exact, tested combinations.
Custom integrations should use defineExternalStoreAdapter() from coaction. See the
adapter contract before writing one.
Examples
- 3D multi-window scene — SharedWorker state across multiple browser windows (demo video).
- Framework examples — React, Vue, Angular, Svelte, Solid.
- Adapter examples — MobX, Pinia, Zustand, and the adapter gallery.
- Middleware examples, vanilla reusable store, and Yjs collaboration.
Docs
- Why Coaction Without Multithreading
- Coaction vs Zustand
- Migrating from Zustand
- Architecture Overview
- Threading Model
- Support Matrix
- Core API Reference
FAQs
Can I use Coaction without multithreading?
Yes — that's the recommended starting point. In single-threaded mode you get the full API, and patch updates stay off for optimal performance.
Do I need @coaction/alien-signals?
No. alien-signals is built into coaction. Use normal getters or get(deps, selector) for
app state; import signal primitives from coaction only for advanced integrations.
Why is Coaction faster than Zustand with Immer?
Coaction uses Mutative, which allows mutable instances for performance. Immer's copy-on-write path is significantly slower.
Does Coaction support CRDTs / multiple tabs?
Yes. Remote sync runs on data-transport, so it suits CRDT apps and multi-tab state (use
SharedWorker to share across tabs). For Yjs specifically, see @coaction/yjs.
Contributing
Start with CONTRIBUTING.md. Security reports follow SECURITY.md, and participation is covered by CODE_OF_CONDUCT.md.
Pull request CI is maintainer-gated: a maintainer adds the run-ci label when a PR is ready.
Once the label is present, later pushes to the same PR keep running CI.
Maintainer Guide
Repository Map
packages/core— runtime creation, authority model, patch flow, transport integration, middleware hooks, adapter hookspackages/coaction-*framework bindings — React, Vue, Angular, Svelte, Solid wrappers around core storespackages/coaction-*state adapters — whole-store integrations for external runtimes (Zustand, MobX, Pinia, Redux, Jotai, Valtio, XState)packages/coaction-*middlewares — logger, persist, history, yjsexamples/*— runnable integration and end-to-end examplesdocs/architecture/*— maintainer-oriented runtime, support, and API-evolution docs
Architecture Map
- Architecture Overview
- Core Runtime
- Threading Model
- Support Matrix
- API Evolution
- Adapter Contract
- DevTools Roadmap
Supported Integration Matrix
| Surface | Official contract |
|---|---|
| Native Coaction stores | Local and shared single/slices stores are supported. |
| Binder-backed adapters | Whole-store only. Shared main/client is currently maintained for MobX, Pinia, and Zustand. |
| Middleware authority | Logger is supported on local/main and limited on clients. Persist and history belong on the authority store. |
| Yjs | Local/main store binding is supported. Client mode is unsupported. |
For the package-by-package status and boundary notes, see the full support matrix.
Testing Pyramid
- Core runtime and type coverage —
packages/core/test - Shared binder adapter coverage —
packages/*/test/contract.test.ts - Package-specific behavior and branch coverage — each package's
test/directory - Integration and end-to-end coverage —
packages/coaction-yjs/test/ws.integration.test.tsandexamples/e2e/test
Run the full gate locally with pnpm check (lint + typecheck + build + package quality/size + tests + e2e).
Contributing a New Adapter
- Read the adapter contract first.
- Follow the adapter contribution guide.
- Add the shared binder contract suite when the package is binder-backed.
- Update the support matrix in the same change as any new guarantee.
Release Flow
Releases run through Changesets:
pnpm changeset— describe the change and pick version bumps.pnpm changeset:check— validate pending changesets.pnpm run version— apply version bumps across the workspace.pnpm run publish -- --provenance— publish with npm Trusted Publishing.
All official packages are versioned together and released as a single line.
Credits
- Concept inspired by Partytown
- API design inspired by Zustand
- Technical reference: React + Redux + Comlink = Off-main-thread
License
Coaction is MIT licensed.





























