Coaction Puts Zustand's API on Vue's Signal Engine
Auto-tracked renders and cached getters are the right design; three majors in four weeks from one maintainer is the catch.
Every few months someone ships "Zustand, but you don't have to write selectors." Most of those attempts bolt a proxy onto the outside of the store. Coaction is the first one I've seen that rebuilds the inside instead: as of 2.0 the store's computed state, render tracking, and getter caching all live on one alien-signals graph, the same push-pull reactivity engine Vue 3.6 adopted for its reactivity rewrite. That's a real architectural idea, not a wrapper. It's also a library with 84 GitHub stars, one meaningful contributor, and around 70 npm downloads a week. Both halves of that sentence matter.
What 2.0 actually changed
Coaction started life in December 2024 as a multithreading play: run a store inside a Web Worker or SharedWorker, mirror it to the page over a patch transport, keep the main thread idle. Its author, Michael Lin (unadlib), also wrote Mutative, the Immer alternative that Coaction uses for draft-style writes. The multithreading pitch never caught on, and the 2.0 release in June flipped the marketing: workers became "the ceiling, not the entry fee," and the headline became single-threaded ergonomics.
Concretely, 2.0 did three things. It rebuilt computed state on alien-signals, so accessor getters on the store (get total() { return this.items.reduce(...) }) become cached computeds that invalidate only when a dependency changes. It added observer() and <Observer> for React, which run the render inside a reactive scope so a component subscribes to exactly the top-level fields it touched. And it formalised defineExternalStoreAdapter() so the MobX, Pinia, Valtio, Redux, Jotai, XState and Zustand adapters share one contract.
The resulting store looks like Zustand with this:
import { create, observer } from '@coaction/react';
const useCart = create((set) => ({
items: [] as { price: number; quantity: number }[],
get total() {
return this.items.reduce((s, i) => s + i.price * i.quantity, 0);
},
add(item) {
set(() => { this.items.push(item); });
},
}));
const Total = observer(() => <b>{useCart().total}</b>);
Writes outside set() throw. Methods pulled off getState() stay bound. There's no useShallow, no useMemo, no reselect.
The idea is older than the library
None of the individual pieces is new, and the project's own comparison docs say so. MobX shipped observer() plus cached computeds in 2016; that's where the name comes from. Valtio gives you proxy-tracked useSnapshot reads on top of a mutable object, and it came from the same maintainer as Zustand. react-tracked retrofits usage tracking onto any hook store. zustand-computed adds derived fields. What Coaction claims is that stacking those four gives you four mental models and four invalidation paths, whereas a single signal graph lets tracking, getters, and the fields they read invalidate together.
That's a sound argument, and alien-signals is a credible foundation: its push phase just flips dirty flags along the dependency graph, and the pull phase recomputes only what's read. If you've watched Vue's reactivity team pick it over their own implementation, you know it's not a toy.
But read the fine print on granularity before you assume Valtio-level precision. Coaction tracks at the store or slice field boundary. Every own enumerable top-level key is a signal slot; nested reads are attributed to the containing field. A component that reads store.user.name re-renders when store.user.email changes. Valtio's proxy tracking goes deeper. And the explicit useStore(selector) path is plain version-plus-Object.is, at parity with Zustand, not better. The win is confined to components you wrap in observer().
About that 18x benchmark
The README's chart shows Coaction at 5,272 ops/sec versus Zustand at 5,233, and "Coaction with Mutative ~18.3x faster than Zustand with Immer" (4,626 vs 253). Two things to understand: that benchmark measures update throughput on 50K arrays and 1K objects, and the 18x gap is really Mutative versus Immer on large drafts, a known result. It tells you nothing about render counts or tracking overhead, which is the feature you'd be adopting the library for. To its credit, the project's benchmarking notes say exactly that: "Do not publish one benchmark as a universal statement that one library is always faster," and a rerender-count comparison is listed as future work. Nobody outside the project has published one either; every performance number in circulation traces back to the author.
The part that should give you pause
The headline says 2.0. The registry says 3.1.0. Coaction went 2.0.0 on 21 June, 2.1.0 on 9 July, 3.0.0 on 12 July, and 3.1.0 on 19 July. Three weeks between majors. The 3.0 change was substantive and breaking: shared mode now enforces a strict lossless-JSON contract (no undefined, Date, BigInt, NaN, or -0 across a worker boundary), a versioned wire protocol with authority epochs, and new coaction/local, coaction/shared, and coaction/adapter entry points so bundlers can drop the transport runtime entirely. 3.1 then rebuilt @coaction/history on Travels 2.1, another unadlib library.
That is a solo maintainer doing good, thoughtful engineering at a pace that no downstream team can absorb. Every @coaction/* binding pins the core with a caret range, so each major means upgrading the whole set in lockstep. Contributions come from unadlib (848 commits), Dependabot, and one bot-authored PR. There's an MIT license, a bilingual docs site, real browser E2E coverage for worker flows, and zero independent reviews or production war stories I could find.
Where it fits, and where it doesn't
If you're already a Zustand shop and your pain is selector sprawl, the honest first move is cheaper than a library swap: put derived data in a zustand-computed or a store-maintained field and stop recomputing in selectors. Coaction's own migration guide assumes you'll keep simple stores structurally identical and only reach for getters and observer() where they delete code, which tells you the delta on a typical codebase is smaller than the pitch.
Where Coaction genuinely earns a spike is a leaf feature with heavy derived state and a plausible worker future: a spreadsheet grid, a collaborative editor, a dashboard that's already choking the main thread. Install coaction @coaction/react, import from coaction/local to keep the transport out of the bundle, and wrap only the hot components in observer(). React 17, 18 and 19 are supported through use-sync-external-store, so it slots into an existing app. Watch two things: whether nested-object updates over-render at the field boundary, and whether cached getters are actually cheaper than a maintained field for your shapes.
What I wouldn't do yet is migrate a production store. The design is the most coherent Zustand-shaped signal store I've seen, and the correct answer to "which one wins" is that alien-signals wins regardless; it's becoming the shared substrate under Vue and now under this. Coaction the library needs a quiet quarter without a major bump, and someone other than its author shipping it, before it's a dependency rather than an experiment.
Sources & further reading
- Coaction 2.0 - Zustand-style state with auto-tracking and signals — github.com
- Coaction releases (2.0.0 through 3.1.0) — github.com
- coaction on npm — npmjs.com
- Why Coaction Without Multithreading — github.com
- Zustand-Focused Benchmarks — github.com
- Unlocking Multiprocessing for Smoother Web Applications — dev.to
- alien-signals: The lightest signal library — github.com
Emeka has spent over a decade tracking threat actors, vulnerability disclosures, and the evolving landscape of application security, bringing a sharp continent-spanning perspective to his reporting. He's known for translating dense CVE advisories into clear, actionable context that developers and security teams alike actually read.
Discussion 0
No comments yet
Be the first to weigh in.