Zustand-style state management with automatic render tracking
and cached computed state on one signal graph,
from single-threaded apps to workers, tabs, and collaboration.
Getting Started · Usage · API Reference · Examples · FAQ
Choosing a state library often starts simple: a store and a few selectors. In selector-heavy or derived-state-heavy apps, that store can accumulate useMemo, reselect-style helpers, useShallow, equality functions, computed-state plugins, immutable-update middleware, and auto-selector generators. Those are valid ecosystem paths, but each adds another cache, subscription, or update model for the application to keep straight. (Redux piles on even more ceremony: actions, reducers, dispatch, and hand-written immutable updates.)
Coaction folds the common pieces into one cohesive signal graph. It keeps a familiar Zustand-style create API, then makes render tracking and derived state first-class:
- Automatic render tracking:
observer()re-renders a component only for the fields it actually 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; in the benchmark below, this update path is ~18x faster than Zustand + Immer. thisand OOP-style actions: natural getters and actions; methods destructured fromgetState()stay bound.
Because tracking, computed values, and the fields they read all live on one alien-signals graph, invalidation is automatic and consistent end to end. The point is not that Zustand cannot assemble similar capabilities through selectors, middleware, guides, or ecosystem packages; the point is that Coaction makes this path one built-in runtime.
And the same store scales up. Built on a transport + patch foundation (data-transport + Mutative), the same store source can run in a Worker or SharedWorker, sync across tabs, or join CRDT-style collaboration through @coaction/yjs and custom transports by choosing the right transport/integration option, not by rewriting your state layer.
Multithreading is the ceiling, not the entry fee. You can adopt Coaction for the single-threaded developer experience first, then grow into shared mode when the architecture calls for it.
The same counter, written both ways:
import { useMemo } from 'react';
import { create as createZustand } from 'zustand';
import { useShallow } from 'zustand/react/shallow';
import { create as createCoaction, observer } from '@coaction/react';
// Zustand: explicit selector + shallow equality + manual memo
const useZustandCounter = createZustand((set) => ({
count: 0,
step: 1,
increment: () => set((state) => ({ count: state.count + state.step }))
}));
function ZustandCounter() {
const { count, step } = useZustandCounter(
useShallow((s) => ({ count: s.count, step: s.step }))
);
const doubled = useMemo(() => count * 2, [count]);
return (
<button onClick={() => useZustandCounter.getState().increment()}>
{count} (step {step}) → {doubled}
</button>
);
}
// Coaction: no selector, cached getter, observer auto-tracking
const useCoactionCounter = createCoaction((set) => ({
count: 0,
step: 1,
get doubled() {
return this.count * 2;
},
increment() {
set(() => {
this.count += this.step;
});
}
}));
const CoactionCounter = observer(() => {
const store = useCoactionCounter(); // tracks only the fields it reads
return (
<button onClick={store.increment}>
{store.count} (step {store.step}) → {store.doubled}
</button>
);
});- Automatic Render Tracking: Wrap React components in
observer()for MobX/Vue-style subscriptions to the store or slice fields read during render, with no selectors required. ExplicituseStore(selector)stays available with Zustand-style equality behavior. - Signal-Backed Computed: Accessor getters are cached by default through the built-in
alien-signalsruntime, whileget(deps, selector)remains available for manual dependencies. - Immutable State with Optional Mutability: Powered by Mutative, providing immutable state transitions with opt-in mutable instances for performance. In the benchmark below, this update path is ~18x faster than Zustand + Immer for that workload.
- Familiar, Minimal API: A Zustand-inspired
createAPI with first-classthisin getters and actions; destructured methods stay bound. - Slices Pattern: Combine multiple slices into a store with namespace support.
- Framework Agnostic: Works with React, Angular, Vue, Svelte, and Solid, plus adapters for state libraries like Redux, Zustand, MobX, Pinia, Jotai, Valtio, and XState.
- Extensible Middleware: Enhance store behavior with logging, time-travel debugging, persistence, and more.
- Multithreading Sync: Share state between webpage and worker threads. With
data-transport, avoid the complexities of message passing and serialization. - Patch-Based Updates: Efficient incremental state changes through patch-based synchronization, useful for worker mirroring and CRDT integrations such as
@coaction/yjs. - Core Signal Exports: Advanced integrations can import
signal,computed,effect, batching helpers, anddefineExternalStoreAdapterdirectly fromcoaction.
The Motivation above makes the short pitch; Why Coaction Without Multithreading is the detailed case. Even with no worker in sight, Coaction can be a more ergonomic Zustand-style store for selector-heavy or derived-state-heavy apps because:
- Automatic store/slice-field tracking:
observer()subscribes a component to the store or slice fields it reads, on the signal graph. No selectors, nouseShallow. - Cached getters:
get value()getters are signal computed values, memoized until a dependency changes. NouseMemoor reselect. - Escape hatches when you want them:
useStore(selector),useStore.auto(), andget(deps, selector)keep explicit control available. this+ getters: natural OOP-style actions and derived values, and methods destructured fromgetState()stay bound.
The value is not that any single feature is unique; Zustand's ecosystem can assemble equivalents. The value is that automatic tracking and computed values share one cohesive signal graph, while explicit selectors and this actions stay available as escape hatches. This comes with honest trade-offs: the published coaction + @coaction/react entry files are ~14 KiB gzip before external dependencies are bundled, versus Zustand's much smaller core.
For React applications:
npm install coaction @coaction/reactFor the core library without any framework:
npm install coactionCoaction includes alien-signals in the core package. You do not need a separate @coaction/alien-signals install.
import { create, observer } from '@coaction/react';
const useStore = create((set) => ({
count: 0,
get doubleCount() {
return this.count * 2;
},
increment() {
set(() => {
this.count += 1;
});
}
}));
const CounterComponent = observer(() => {
const store = useStore();
return (
<div>
<p>Count: {store.count}</p>
<p>Double: {store.doubleCount}</p>
<button onClick={store.increment}>Increment</button>
</div>
);
});In React, wrap components with observer() for MobX/Vue-style automatic render tracking without selectors. Plain useStore() outside observer() remains a whole-store subscription; use useStore(selector) or useStore.auto() when you prefer explicit React selector subscriptions.
counter.js
export const counter = (set) => ({
count: 0,
increment: () => set((state) => state.count++)
});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 });
const CounterComponent = () => {
const store = useStore();
return (
<div>
<p>Count in Worker: {store.count}</p>
<button onClick={() => store.increment()}>Increment</button>
</div>
);
};import { create } from '@coaction/react';
const counter = (set, get) => ({
count: 0,
// accessor getters are cached automatically
get tripleCount() {
return this.count * 3;
},
// explicit dependency form for manual dependency control
doubleCount: get(
(state) => [state.counter.count],
(count) => count * 2
),
increment() {
set(() => {
// you can use `this` to access the slice state
this.count += 1;
});
}
});
const useStore = create(
{
counter
},
{
sliceMode: 'slices'
}
);Accessor getters are the default derived-state API. Coaction wraps them in alien-signals computed values, so repeated reads are cached until their state dependencies change. Use get(deps, selector) when you want explicit manual dependencies, for example cross-slice derived data or adapter integration code.
Methods that rely on this stay bound when you destructure them from getState():
const { increment } = useStore.getState().counter;
increment();Coaction operates in two primary modes:
The store is managed entirely within the webpage thread. Patch updates are disabled by default for optimal performance.
The worker thread serves as the primary source of shared state, utilizing transport for synchronization. Webpage threads act as clients, accessing and manipulating the state asynchronously.
In shared mode, the library automatically determines the execution context based on transport parameters, handling synchronization seamlessly. You can easily support multiple tabs, multithreading, or multiprocessing.
For a 3D scene shared across several tabs, you can effortlessly handle state management using Coaction:
coaction-example.mp4
Shared Mode Sequence Diagram
sequenceDiagram
participant Client as Webpage Thread (Client)
participant Main as Worker Thread (Main)
activate Client
Note over Client: Start Worker Thread
activate Main
Client ->> Main: Trigger fullSync event after startup
activate Main
Main -->> Client: Synchronize data (full state)
deactivate Main
Note over Client: User triggers a UI event
Client ->> Main: Send Store method and parameters
activate Main
Main ->> Main: Execute the corresponding method
Main -->> Client: Synchronize state (patches)
Note over Client: Render new state
Main -->> Client: Asynchronously respond with method execution result
deactivate Main
deactivate Client
Benchmark measuring ops/sec to update 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 key difference emerges with immutable helpers: Coaction with Mutative is ~18.3x faster than Zustand with Immer (4,626 vs 253 ops/sec), thanks to Mutative's efficient state update mechanism.
Zustand is intentionally small and middleware/ecosystem-first. Coaction keeps a familiar Zustand-style creation API, but chooses a larger default runtime. This table compares what is first-class in Coaction with Zustand's core or official path; "not built in" does not mean Zustand cannot support the pattern.
| Capability | Coaction | Zustand core / official path |
|---|---|---|
| Worker-backed shared state | Built in via main/client store modes | Vanilla stores can run outside React; sync is userland |
| Signal-backed cached getters | Built in | Selectors, memoized helpers, or ecosystem packages |
Explicit computed deps via get() |
Built in | Userland selector or computed-state middleware |
| Observer automatic React tracking | Built in via observer() |
Selector subscriptions; usage tracking requires userland |
| Namespaced slices | Built-in sliceMode: 'slices' |
Official slice composition pattern, not a runtime namespace |
| Auto selector generation | Built in via useStore.auto() |
Official guide and ecosystem utilities |
| Multiple-store selector | Built in via createSelector() |
Compose selectors/hooks in application code |
| External store adapter API | Formal whole-store adapter contract | Middleware and ecosystem-first extension model |
| Middleware model | Store-object middleware hooks | Mature middleware stack (persist, devtools, etc.) |
this in getter/action |
Bound to store or slice state | Intentional closure/selector style |
Coaction uses alien-signals internally for cached computed getters and observer() render tracking; no separate @coaction/alien-signals package is required. Explicit useStore(selector) uses Zustand-style equality checks.
For the single-threaded value proposition, see Why Coaction Without Multithreading. For a deeper side-by-side comparison, see Coaction vs Zustand. For existing Zustand codebases, see Migrating from Zustand.
Zustand remains the smaller, more mature choice when selectors and middleware cover the problem cleanly. Coaction is designed for teams that want computed state, render tracking, namespaced slices, external adapters, and worker-ready synchronization to live behind one runtime contract.
- Core API index
- Core API notes
- Why Coaction Without Multithreading
- Coaction vs Zustand
- Migrating from Zustand
- Zustand-focused benchmarks
- DevTools roadmap
- Architecture overview
- Support matrix
- API evolution
Regenerate the reference from source with pnpm docs:api.
Coaction folds alien-signals into the core package. Normal stores do not need direct signal usage, but integration authors can use the native primitives without installing an extra package:
import {
computed,
defineExternalStoreAdapter,
effect,
effectScope,
endBatch,
signal,
startBatch,
trigger
} from 'coaction';Use these exports for framework bindings, external store adapters, and advanced reactivity integrations. For application state, prefer Coaction getters and get(deps, selector).
create() infers store shape from createState by default (sliceMode: 'auto'). For backward compatibility, auto still treats a non-empty object whose enumerable values are all functions as slices. That shape is ambiguous with a plain store that only contains methods, so development builds warn and you should set sliceMode explicitly.
'single': Treat an object as a single store, even if all values are functions.'slices': Strict slices mode with validation.
const singleStore = create(
{
ping() {
return 'pong';
}
},
{ sliceMode: 'single' }
);
const slicesStore = create(
{
counter: (set) => ({
count: 0,
increment() {
set((draft) => {
draft.counter.count += 1;
});
}
})
},
{ sliceMode: 'slices' }
);Refactor a general store into a multithreading reusable store: the same source runs on both the webpage and the worker, with isolated references but synchronized state:
store.js
+ 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((draft) => {
draft.count += 1;
});
}
}),
+ { worker }
);TypeScript note: In the webpage context, the store type is
AsyncStore(methods become asynchronous and are proxied to the worker). In the worker context, it'sStore. See the reusable store example.
Coaction is designed to work with a wide range of libraries and frameworks.
| Framework | Package |
|---|---|
| React | @coaction/react |
| Vue | @coaction/vue |
| Angular | @coaction/ng |
| Svelte | @coaction/svelte |
| Solid | @coaction/solid |
| Yjs | @coaction/yjs |
| Library | Package |
|---|---|
| MobX | @coaction/mobx |
| Pinia | @coaction/pinia |
| Zustand | @coaction/zustand |
| Redux Toolkit | @coaction/redux |
| Jotai | @coaction/jotai |
| XState | @coaction/xstate |
| Valtio | @coaction/valtio |
Note: Slices mode is a core
coactionfeature. Third-party state adapters only support whole-store binding.
Custom state-library integrations should use defineExternalStoreAdapter() from coaction. The adapter API bridges an external whole-store runtime while keeping Coaction subscriptions and signal-backed selectors refreshed.
| Middleware | Package |
|---|---|
| Logger | @coaction/logger |
| Persist | @coaction/persist |
| Undo/Redo | @coaction/history |
For production collaboration setups with @coaction/yjs, see:
Do I need `@coaction/alien-signals`?
No. In Coaction 2.0, alien-signals is built into coaction. Import native signal primitives from coaction for advanced integrations, and use normal getter accessors or get(deps, selector) for application derived state.
Can I use Coaction without multithreading?
Absolutely. Coaction supports single-threaded mode with its full API. In default single-threaded mode, it doesn't use patch updates, ensuring optimal performance.
Why is Coaction faster than Zustand with Immer?
Coaction uses Mutative, which provides a faster state update mechanism. Mutative allows mutable instances for performance optimization, whereas Immer's pure immutable approach incurs more overhead.
Why can Coaction integrate with both observable and immutable state libraries?
Coaction is built on Mutative, so it works regardless of whether the state library is immutable or observable. It binds to the existing state object, obtains patches through proxy execution, and applies them to the third-party state library.
Does Coaction support CRDTs?
Yes. Coaction achieves remote synchronization through data-transport, making it well-suited for CRDTs applications. For Yjs-specific synchronization, see the @coaction/yjs documentation.
Does Coaction support multiple tabs?
Yes. State synchronization between multiple tabs is supported via data-transport. Consider using SharedWorker for sharing state across tabs.
Start with CONTRIBUTING.md. Security reports should follow SECURITY.md, and participation is covered by CODE_OF_CONDUCT.md.
Pull request CI is intentionally maintainer-gated: a maintainer adds the run-ci label when a PR is ready for CI. After that label is present, later pushes to the same PR continue to run the PR workflow.
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 such as Zustand, MobX, Pinia, Redux, Jotai, Valtio, and XStatepackages/coaction-*middlewares: logger, persist, history, yjsexamples/*: runnable integration and end-to-end examplesdocs/architecture/*: maintainer-oriented runtime, support, and API evolution docs
| 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 lives in
packages/core/test. - Shared binder adapter coverage lives in
packages/*/test/contract.test.ts. - Package-specific behavior and branch coverage lives in each package
test/directory. - Integration and end-to-end coverage lives in
packages/coaction-yjs/test/ws.integration.test.tsandexamples/e2e/test.
- 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.
- Concept inspired by Partytown
- API design inspired by Zustand
- Technical reference: React + Redux + Comlink = Off-main-thread
Coaction is MIT licensed.

