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
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>
);
}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.
For the core library without any framework:
npm install coactionFor React applications:
npm install coaction @coaction/reactWorks 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 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.
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.
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.
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)
)
}));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 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 sliceEverything 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.
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.
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.
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.
- 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.
- Why Coaction Without Multithreading
- Coaction vs Zustand
- Migrating from Zustand
- Architecture Overview
- Threading Model
- Support Matrix
- Core API Reference
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.
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
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 Overview
- Core Runtime
- Threading Model
- Support Matrix
- API Evolution
- Adapter Contract
- DevTools Roadmap
| 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.
- 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).
- 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.
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.
- Concept inspired by Partytown
- API design inspired by Zustand
- Technical reference: React + Redux + Comlink = Off-main-thread
Coaction is MIT licensed.

