Skip to content

coactionjs/coaction

Folders and files

NameName
Last commit message
Last commit date

Latest commit

 

History

744 Commits
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

Coaction Logo

Coaction

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.

Node CI npm License

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 trackingobserver() re-renders a component only for the fields it reads. No selectors, no useShallow.
  • Cached computed by defaultget value() getters memoize until a dependency changes. No useMemo, no reselect.
  • Mutable writes, immutable results — just this.count += 1 inside set(). Powered by Mutative (~18x faster than Zustand + Immer in our benchmark).
  • this + OOP-style actions — natural getters and actions; methods destructured from getState() stay bound.
  • Escape hatches when you want themuseStore(selector), useStore.auto(), and get(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.

Coaction Concept

Install

For the core library without any framework:

npm install coaction

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 in observer() 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 synchronous Store.

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

Docs

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 hooks
  • packages/coaction-* framework bindings — React, Vue, Angular, Svelte, Solid wrappers around core stores
  • packages/coaction-* state adapters — whole-store integrations for external runtimes (Zustand, MobX, Pinia, Redux, Jotai, Valtio, 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

Run the full gate locally with pnpm check (lint + typecheck + build + package quality/size + tests + e2e).

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.

Release Flow

Releases run through Changesets:

  1. pnpm changeset — describe the change and pick version bumps.
  2. pnpm changeset:check — validate pending changesets.
  3. pnpm run version — apply version bumps across the workspace.
  4. pnpm run publish -- --provenance — publish with npm Trusted Publishing.

All official packages are versioned together and released as a single line.

Credits

License

Coaction is MIT licensed.

About

Zustand-style state management with built-in render tracking and cached computed state — from single-threaded apps to workers, tabs, and collaboration.

Topics

Resources

License

Code of conduct

Contributing

Security policy

Stars

Watchers

Forks

Packages

 
 
 

Contributors