Why Coaction Without Multithreading
June 23, 2026 · View on GitHub
Multithreading is Coaction's headline capability, but most teams reach for a state library to solve everyday single-threaded problems first. This page makes the case that even with no worker in sight, Coaction is a more ergonomic Zustand-style store — and it tries to be honest about where Zustand still wins.
The argument is not that any single feature here is unique. Zustand's ecosystem can assemble equivalents for each of them (react-tracked for usage tracking, zustand-computed / @csark0812/zustand-getters for derived state, auto-selector generators for boilerplate). The argument is that Coaction keeps automatic tracking and getter/computed invalidation on one signal graph, while explicit selectors and this ergonomics stay available as escape hatches, instead of four plugins with four mental models.
The four single-thread pillars
1. observer() — automatic store/slice-field tracking
Wrap a component in observer() and it subscribes to exactly the fields it reads during render at the store or slice boundary. No selector, no useShallow.
import { create, observer } from '@coaction/react';
const useStore = create((set) => ({
first: 'Ada',
last: 'Lovelace',
age: 36,
birthday() {
set(() => {
this.age += 1;
});
}
}));
const Name = observer(() => {
const store = useStore();
// reads top-level first + last → re-renders only when first or last change.
// `age` changes do NOT re-render this component.
return (
<span>
{store.first} {store.last}
</span>
);
});
This is real signal tracking, not a render-time diff. observer() runs the render inside an alien-signals reactive scope, and every own enumerable state field on the store or slice is a signal slot, so reads register dependencies at that boundary. Nested object reads are tracked through the containing field, so changing a sibling property on the same nested object can invalidate the same subscription.
Caveat: cached getters and
get(deps, selector)computed values are not available on mutable external-adapter instances.observer()readers still receive adapter updates, but the same store/slice-field granularity boundary applies.
2. Cached getters — derived state that memoizes itself
Accessor getters are wrapped in alien-signals computed values, so repeated reads are cached until a dependency changes. No useMemo, no reselect.
const useCart = create((set) => ({
items: [] as Array<{ price: number; quantity: number }>,
get total() {
// cached; recomputed only when `items` changes
return this.items.reduce((sum, i) => sum + i.price * i.quantity, 0);
},
add(item: { price: number; quantity: number }) {
set(() => {
this.items.push(item);
});
}
}));
3. Escape hatches — explicit control when you want it
Automatic tracking is the default, not a cage. When you prefer explicit subscriptions you keep the full Zustand-style toolbox plus a couple of extras:
// classic selector (familiar Zustand DX)
const total = useCart((state) => state.total);
// cached auto-selector map
const selectors = useCart.auto();
const total2 = useCart(selectors.total);
// explicit computed dependencies (cross-slice / adapter code)
const cart = (set, get) => ({
items: [] as Array<{ price: number; quantity: number }>,
total: get(
(state) => [state.cart.items],
(items) => items.reduce((sum, i) => sum + i.price * i.quantity, 0)
)
});
Be precise about this one. The explicit
useStore(selector)path is version + recompute +Object.is, the same model Zustand uses. It does not add signal-level fine-grained invalidation on its own. The store/slice-field tracking behavior comes fromobserver(). So on explicit selectors Coaction is at parity with Zustand; the increment is thatobserver()lets you skip selectors entirely.
4. this and getters — natural OOP-style ergonomics
You can use this in getters and actions, and destructured methods stay bound.
const { birthday } = useStore.getState();
birthday(); // still works — `this` stays bound to the store or slice
Actions are bound at call time to the current state target, which is why destructuring does not drop this. Zustand intentionally avoids this, so the same derived/action code there usually goes through selectors or external helpers.
Single-thread before / after
The same counter, written for both libraries.
// Zustand: explicit selector + shallow equality + manual memo for derived value
import { create } from 'zustand';
import { useShallow } from 'zustand/react/shallow';
import { useMemo } from 'react';
const useStore = create((set) => ({
count: 0,
step: 1,
increment: () => set((s) => ({ count: s.count + s.step }))
}));
function Counter() {
const { count, step } = useStore(
useShallow((s) => ({ count: s.count, step: s.step }))
);
const doubled = useMemo(() => count * 2, [count]);
return (
<button onClick={() => useStore.getState().increment()}>
{count} (step {step}) → {doubled}
</button>
);
}
// Coaction: no selector, no useShallow, cached getter, `this` actions
import { create, observer } from '@coaction/react';
const useStore = create((set) => ({
count: 0,
step: 1,
get doubled() {
return this.count * 2;
},
increment() {
set(() => {
this.count += this.step;
});
}
}));
const Counter = observer(() => {
const store = useStore();
return (
<button onClick={store.increment}>
{store.count} (step {store.step}) → {store.doubled}
</button>
);
});
Why "one cohesive model" actually matters
In Zustand you would typically reach the equivalent default experience by stacking independent packages:
Zustand
+ react-tracked // automatic usage tracking (Proxy)
+ zustand-computed / getters // derived values
+ auto-selectors / zustand-x // selector boilerplate
+ immer (or mutative) // ergonomic updates
Each of those is a separate mechanism: react-tracked's render-time Proxy snapshot, a computed middleware's derived state, selector equality functions, and an immutable-update layer. They combine into something similar, but no single dependency graph guarantees they stay consistent with each other.
Coaction's tracking and computed pieces share one alien-signals graph: a cached getter reading a field, a component reading that getter, and the field itself are connected, so invalidation is automatic and consistent end to end. That shared substrate — not "one package instead of four" — is the real structural advantage.
There is also a supply-chain / maintenance dimension the feature table hides: the stacked approach is four independently maintained packages with an N×N compatibility matrix across React and Zustand major versions. Coaction is a single version contract maintained as one unit.
The honest costs
Cohesion has a price, and the pitch is more credible if it says so:
- Bundle size. Coaction's core depends on
alien-signals,data-transport, andmutative, and the React binding also depends onuse-sync-external-store. The package-size budget gzips each published entry file, not a dependency-inclusive app bundle. Current ESM entry-file baselines (seescripts/package-size-budgets.json) arecoaction≈ 11.3 KiB and@coaction/react≈ 2.9 KiB, so the published entry files total ~14 KiB gzip before external dependencies are bundled. A real app bundle will include the external runtime dependencies your bundler retains, while Zustand's core remains much smaller. - A runtime model to learn. Proxy/signal-backed state and the
observer()convention are concepts Zustand's "thin, no-magic" core deliberately avoids. For teams that prize explicitness, that is a feature of Zustand, not a gap. - Explicit selectors are only at parity. As noted above,
useStore(selector)recomputes like Zustand; the differentiation is concentrated inobserver()and cached getters. - Maturity. Zustand has a far larger ecosystem and battle-tested track record. Coaction's integrated model is younger.
When Zustand is still the better choice
- you only need a small hook store with a few selectors
- bundle minimalism and a near-zero-dependency core are top priorities
- the team prefers explicit, magic-free subscriptions
- derived data is happy living in selectors or local memoization
When Coaction's single-thread DX pays off
- selector-heavy components and repeated derived state
- you want derived values cached by default without
useMemo/reselect - you prefer
this/getter ergonomics and mutable-style updates - you would otherwise assemble
react-tracked+ a computed plugin + auto selectors and maintain that stack yourself
For the broader feature-by-feature comparison, see Coaction vs Zustand. For moving an existing codebase, see Migrating from Zustand.