Skip to content

coactionjs/coaction

Folders and files

NameName
Last commit message
Last commit date

Latest commit

 

History

739 Commits
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Coaction Logo

Coaction

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.

Node CI Coverage npm License

Getting Started · Usage · API Reference · Examples · FAQ


Motivation

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, no useShallow.
  • Cached computed by default: get value() getters memoize until a dependency changes. No useMemo, no reselect.
  • Mutable writes, immutable results: just this.count += 1 inside set(). Powered by Mutative; in the benchmark below, this update path is ~18x faster than Zustand + Immer.
  • this and OOP-style actions: natural getters and actions; methods destructured from getState() 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.

Coaction Concept

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.

Quick look

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>
  );
});

Features

  • 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. Explicit useStore(selector) stays available with Zustand-style equality behavior.
  • Signal-Backed Computed: Accessor getters are cached by default through the built-in alien-signals runtime, while get(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 create API with first-class this in 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, and defineExternalStoreAdapter directly from coaction.

Why Coaction Even Without Multithreading

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:

  1. Automatic store/slice-field tracking: observer() subscribes a component to the store or slice fields it reads, on the signal graph. No selectors, no useShallow.
  2. Cached getters: get value() getters are signal computed values, memoized until a dependency changes. No useMemo or reselect.
  3. Escape hatches when you want them: useStore(selector), useStore.auto(), and get(deps, selector) keep explicit control available.
  4. this + getters: natural OOP-style actions and derived values, and methods destructured from getState() 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.

Installation

For React applications:

npm install coaction @coaction/react

For the core library without any framework:

npm install coaction

Coaction includes alien-signals in the core package. You do not need a separate @coaction/alien-signals install.

Usage

Standard Mode Store

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.

Shared Mode Store

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>
  );
};

Slices Pattern & Derived Data

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();

Operating Modes

Coaction operates in two primary modes:

Standard Mode

The store is managed entirely within the webpage thread. Patch updates are disabled by default for optimal performance.

Shared Mode

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.

Examples

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
Loading

Performance

Benchmark measuring ops/sec to update 50K arrays and 1K objects, higher is better (source).

Benchmark snapshot from the current scripts/benchmark.ts comparison

Benchmark

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.

Coaction vs Zustand

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.

API Reference

Regenerate the reference from source with pnpm docs:api.

Signal Runtime Exports

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).

Store Shape Mode (sliceMode)

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' }
);

Reusable Store

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's Store. See the reusable store example.

Integration

Coaction is designed to work with a wide range of libraries and frameworks.

Frameworks

Framework Package
React @coaction/react
Vue @coaction/vue
Angular @coaction/ng
Svelte @coaction/svelte
Solid @coaction/solid
Yjs @coaction/yjs

State Management Libraries

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 coaction feature. 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.

Middlewares

Middleware Package
Logger @coaction/logger
Persist @coaction/persist
Undo/Redo @coaction/history

Yjs Collaboration

For production collaboration setups with @coaction/yjs, see:

FAQs

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.

Contributing

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

Repository Map

  • packages/core: runtime creation, authority model, patch flow, transport integration, middleware hooks, adapter hooks
  • packages/coaction-* framework bindings: React, Vue, Angular, Svelte, Solid wrappers around core stores
  • packages/coaction-* state adapters: whole-store integrations for external runtimes such as Zustand, MobX, Pinia, Redux, Jotai, Valtio, and XState
  • packages/coaction-* middlewares: logger, persist, history, yjs
  • examples/*: runnable integration and end-to-end examples
  • docs/architecture/*: maintainer-oriented runtime, support, and API evolution docs

Architecture Map

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

Contributing a New Adapter

  1. Read the adapter contract first.
  2. Follow the adapter contribution guide.
  3. Add the shared binder contract suite when the package is binder-backed.
  4. Update the support matrix in the same change as any new guarantee.

Credits

License

Coaction is MIT licensed.