Reads Are Subscriptions - Migrating from Zustand to Coaction
2026-08-31
I used Zustand for a long time.
It is small, fast, and refreshingly restrained. If a project needs a handful of simple hook stores, Zustand is still one of the easiest state libraries to recommend.
But as a codebase grows, two kinds of infrastructure keep appearing around the store.
The first is observation infrastructure:
- selectors for each component;
useShallowand equality functions;- selector composition and referential stability;
- keeping “what this component reads” aligned with “what this component subscribes to”.
The second is computation infrastructure:
useMemofor values derived from store state;- shared Reselect or
proxy-memoizeselectors; - deciding where caches live and how long they live;
- composing one derived value from another;
- keeping dependency declarations synchronized with the actual reads.
None of these mechanisms is bad. In fact, Zustand is intentionally designed so that the application can choose them.
The problem is that, beyond a certain scale, they stop feeling like incidental application code. They become a second state-management layer that your team has to design, review, debug, and keep consistent.
That is the distinction I want to make in this article.
It is not primarily:
Zustand has more boilerplate; Coaction has fewer lines.
It is:
Zustand largely leaves the dependency model to application code. Coaction moves both observation and derived computation into the runtime.
Those are two separate questions:
| Question | What it means |
|---|---|
| Who needs to react? | Observation / subscription dependencies |
| What needs to recompute? | Derived / computation dependencies |
In Zustand, selectors and memoization answer those questions outside the store runtime.
In Coaction, both participate in one reactive graph.
That difference is small in a counter. It becomes much more important in an application with hundreds of state reads and a large amount of shared derived data.
First, let’s put a number on “large”
“It gets harder when the project gets big” is not useful unless we say what kind of scale we are talking about.
Here is one plausible shape for a roughly 100k-line frontend application:
| Dimension | Plausible range | Midpoint |
|---|---|---|
| Application source | — | ~100,000 LOC |
| Components | 280–450 | ~350 |
| Zustand stores | 8–25 | ~15 |
| Components reading store state | 120–220 | ~160 |
| Selector call sites | 250–550 | ~380 |
useShallow uses |
30–80 | ~55 |
useMemo uses, total |
200–330 | ~260 |
| …of which purely store-derived | 60–120 | ~85 |
This table is an order-of-magnitude estimate, not a measurement from one migration. Its purpose is to give you something to compare your own project against.
You can count some of the obvious signals directly:
grep -rEc "use[A-Z][A-Za-z]*\((state|s)\s*=>" src \
| awk -F: '{n+=$2} END {print "selector call sites:", n+0}'
grep -rc "useShallow" src \
| awk -F: '{n+=$2} END {print "useShallow:", n+0}'
grep -rc "useMemo(" src \
| awk -F: '{n+=$2} END {print "useMemo:", n+0}'
If you count a few dozen selectors and almost no shared derived state, the premise of this article probably does not apply to you. Stay on Zustand.
The interesting case is when selectors, caches, and derived relationships appear everywhere.
At that scale, Coaction’s trade starts to make more sense. It moves three things into the store model:
- Reactive read tracking — the runtime records the state paths a consumer actually reads.
- Cached computed state — getters are runtime-owned computed nodes with dependency-aware invalidation.
- Draft updates — mutable syntax with immutable results.
The first two are the architectural argument. The third is useful ergonomics.
Being fair first: simple Zustand is already good enough
Start with an ordinary counter:
import { create } from "zustand";
type CounterStore = {
count: number;
step: number;
increment: () => void;
};
const useCounter = create<CounterStore>((set) => ({
count: 0,
step: 1,
increment: () =>
set((state) => ({
count: state.count + state.step,
})),
}));
function Counter() {
const count = useCounter((state) => state.count);
const step = useCounter((state) => state.step);
const increment = useCounter((state) => state.increment);
const doubled = count * 2;
return (
<button onClick={increment}>
{count} (step {step}) → {doubled}
</button>
);
}
This is good code.
There is no reason to put count * 2 into useMemo. Selecting count, step, and increment separately is idiomatic Zustand. There is no meaningful subscription problem here.
Zustand’s minimalism is an advantage when the problem is minimal.
The difference starts to matter when the state graph gets richer than the component tree.
Graph one: observation — reads are subscriptions
The same counter in Coaction:
import { create, observer } from "@coaction/react";
const useCounter = create((set) => ({
count: 0,
step: 1,
get doubled() {
return this.count * 2;
},
increment() {
set(() => {
this.count += this.step;
});
},
}));
const Counter = observer(() => {
const store = useCounter();
return (
<button onClick={store.increment}>
{store.count} (step {store.step}) → {store.doubled}
</button>
);
});
For an example this small, saving a few lines is not important.
The important difference is the observation model.
In Zustand, the component usually declares its subscription separately from its reads:
const name = useUser((state) => state.user.profile.name);
Conceptually:
render logic
│
└──── reads name
selector logic
│
└──── declares subscription to name
The two pieces of code happen to agree because the developer made them agree.
In Coaction, the read is itself the subscription declaration:
const UserName = observer(() => {
const store = useUser();
return <span>{store.user.profile.name}</span>;
});
Conceptually:
render
│
└── read user.profile.name
│
└── runtime records dependency
In short:
Reads are subscriptions.
Inside observer() or a tracked React selector, native Coaction state is read through readonly reactive proxies, including nested paths. Reading outside a tracked consumer does not create a subscription by itself. If a component reads:
store.user.profile.name;
then a sibling write such as:
useUser.setState((draft) => {
draft.user.profile.age = 37;
});
can leave a dependency that only read name cached. Parent replacement, array structure changes and graph snapshot patches can invalidate more broadly; this example is an age-only recipe update, not a promise about every way to replace user.
That is a different model from “the whole store changed, now rerun every selector and compare its result.”
The runtime knows the path that was observed.
This is the first structural advantage:
Zustand’s default observation model is selector + equality. Coaction can make observation dependency-aware.
That does not mean selectors disappear completely. Explicit selectors remain useful, especially when you want a particular projection or library-style API. But they are no longer the only way to express a subscription.
Coaction’s useStore(selector) uses the same managed deep tracking. Selector execution and React rendering are separate: unrelated recipe writes can skip the selector, and Object.is compares the result when it does run. observer() collects reads from the render itself instead of requiring that selector.
Graph two: computation — one derived node, many consumers
This is the more important difference.
Consider a cart:
type CartItem = {
id: string;
price: number;
quantity: number;
selected: boolean;
};
type CartStore = {
items: CartItem[];
filter: "all" | "selected";
discountRate: number;
};
We want:
items + filter
│
▼
visibleItems
│
▼
subtotal
│
├──────── discountRate
│ │
└──────┬───────┘
▼
total
And several components need those values:
┌── CartList
│
items + filter ──→ visibleItems ──┼── CartCount
│
└── subtotal ──→ total ──→ CartSummary
The important question is not whether JavaScript can express those calculations.
Of course it can.
The question is:
Does the state runtime understand that
visibleItems,subtotal, andtotalare shared computation nodes?
The component-local Zustand version
A common first version is to derive the values in a component:
import { useMemo } from "react";
import { create } from "zustand";
import { useShallow } from "zustand/react/shallow";
const useCart = create<CartStore>(() => ({
items: [],
filter: "all",
discountRate: 0,
}));
function CartSummary() {
const { items, filter, discountRate } = useCart(
useShallow((state) => ({
items: state.items,
filter: state.filter,
discountRate: state.discountRate,
})),
);
const visibleItems = useMemo(() => {
return filter === "selected"
? items.filter((item) => item.selected)
: items;
}, [items, filter]);
const total = useMemo(() => {
const subtotal = visibleItems.reduce(
(sum, item) => sum + item.price * item.quantity,
0,
);
return subtotal * (1 - discountRate);
}, [visibleItems, discountRate]);
return (
<section>
<p>Items: {visibleItems.length}</p>
<p>Total: {total}</p>
</section>
);
}
This is not wrong.
But now add CartList, CartCount, CheckoutButton, and a sidebar that all need visibleItems or total.
A component-local memo belongs to one component instance. It is not an application-level derived node.
You start duplicating either the calculation or the memoization policy.
The strongest Zustand answer is a shared memoized selector
A serious Zustand codebase does not have to leave expensive shared derivation inside components.
You can use Reselect:
import { createSelector } from "reselect";
const selectItems = (state: CartStore) => state.items;
const selectFilter = (state: CartStore) => state.filter;
const selectDiscountRate = (state: CartStore) => state.discountRate;
const selectVisibleItems = createSelector(
[selectItems, selectFilter],
(items, filter) =>
filter === "selected" ? items.filter((item) => item.selected) : items,
);
const selectSubtotal = createSelector([selectVisibleItems], (items) =>
items.reduce((sum, item) => sum + item.price * item.quantity, 0),
);
const selectTotal = createSelector(
[selectSubtotal, selectDiscountRate],
(subtotal, discountRate) => subtotal * (1 - discountRate),
);
And then reuse the same selector instances:
function CartList() {
const items = useCart(selectVisibleItems);
return <List items={items} />;
}
function CartCount() {
const items = useCart(selectVisibleItems);
return <span>{items.length}</span>;
}
function CartSummary() {
const total = useCart(selectTotal);
return <span>{total}</span>;
}
This is a good solution.
It can reuse cached results across consumers that reuse the same memoized selector instance.
So the argument for Coaction cannot honestly be:
Zustand recomputes everything and Coaction caches it.
Zustand can cache it.
The real difference is ownership.
With Zustand + Reselect, the application owns:
selector identity
dependency declaration
memoization policy
cache scope
selector composition
parameterized selector factories
cache lifetime
Zustand itself still sees something closer to:
state changed
│
▼
subscriber calls selector function
│
▼
selector's own memoization decides whether to compute
The selector graph exists, but it exists in application-level functions layered on top of Zustand.
That distinction matters because the store runtime itself does not own visibleItems as a first-class computation node.
Coaction: the computation belongs to the runtime
The same relationships in Coaction:
import { create } from "@coaction/react";
const useCart = create((set) => ({
items: [] as CartItem[],
filter: "all" as "all" | "selected",
discountRate: 0,
get visibleItems() {
return this.filter === "selected"
? this.items.filter((item) => item.selected)
: this.items;
},
get subtotal() {
return this.visibleItems.reduce(
(sum, item) => sum + item.price * item.quantity,
0,
);
},
get total() {
return this.subtotal * (1 - this.discountRate);
},
addItem(item: CartItem) {
set(() => {
this.items.push(item);
});
},
}));
Now the graph is part of the state runtime:
items ───────┐
│
filter ──────┴──→ visibleItems
│
▼
subtotal ─────┐
│
discountRate ──────────────────┴──→ total
And the consumers attach to those nodes:
const CartList = observer(() => {
const store = useCart();
return <List items={store.visibleItems} />;
});
const CartCount = observer(() => {
const store = useCart();
return <span>{store.visibleItems.length}</span>;
});
const CartSummary = observer(() => {
const store = useCart();
return <span>{store.total}</span>;
});
The important property is not the getter syntax.
It is this:
3 consumers
1 visibleItems node
1 cache
1 invalidation relationship
When a dependency changes, the computed becomes invalid.
When it is read again, it recomputes and caches the result.
Other consumers of the same runtime read the same cached node rather than owning independent component caches.
And derived state composes naturally:
visibleItems
│
▼
subtotal
│
▼
total
A getter can read another getter. Those are not merely functions calling functions; they are connected through the same reactive substrate.
The dependency granularity matters here. Native getter + this is cached, but it is not deep by default in 4.0. Getters read frozen snapshots at the store/slice field boundary. A getter that reads this.user.name also invalidates when user.age changes. In the cart above, an items change can invalidate the array-derived getters even when the edited item would not affect the selected result. Recomputing a getter does not necessarily re-render its consumers: an unchanged result can suppress downstream work.
get(deps, fn) keeps that frozen-snapshot boundary too. Selecting state.user.name as an explicit dependency lets fn reuse its result when the name is equal, although the dependency selector can run again after an age change.
For a managed computed read that needs deep dependencies, use the optional coaction/derived entry:
import { create } from "coaction";
import { derive, derivePath } from "coaction/derived";
const person = create({ user: { name: "Ada", age: 36 } });
const greeting = derive(person, (state) => `Hello, ${state.user.name}`, {
deep: true,
});
const name = derivePath(person, ["user", "name"]);
greeting(); // "Hello, Ada"
person.setState((draft) => {
draft.user.age++;
});
greeting(); // cached: the name did not change
name(); // "Ada"
greeting.dispose();
name.dispose();
person.destroy();
These instances belong to the store that owns them. Share an instance when consumers need one shared cache, and dispose it when its owner no longer needs it. Deep tracking observes values and structure; object-identity comparisons need the documented identity() markers. It does not turn filter, sort or reduce into incremental algorithms. The computed guide covers those boundaries.
This is the second structural advantage:
Zustand can build shared derived computation around the store. Coaction makes shared derived computation part of the store runtime.
The deeper difference: two graphs, one runtime
This is the part I think is easy to miss if the comparison is framed only as “selectors vs no selectors” or “useMemo vs getters”.
There are really two dependency graphs.
Observation graph
It answers:
Who needs to react when state changes?
Example:
user.profile.name
│
├── Header
└── AccountMenu
If user.profile.age changes, consumers that only observed name should not care.
Computation graph
It answers:
What needs to recompute when state changes?
Example:
items ──→ visibleItems ──→ subtotal ──→ total
filter ───────┘ ↑
discountRate ──────────────────────────┘
Those are related but different problems.
A state runtime that understands only subscriptions still leaves derived computation to application code.
A computation system that caches values but does not integrate with observation still leaves the UI boundary to another mechanism.
Coaction’s architectural bet is that both should sit on the same reactive graph.
That produces a much simpler invariant:
state read
│
▼
dependency recorded
│
▼
source changes
│
▼
observers/computeds with affected dependencies invalidate
“Affected” uses each reader’s dependency granularity: leaf paths for managed deep readers, store/slice fields for native getters, and broader invalidation where replacements or graph structure require it. Sharing a reactive graph does not make all those readers equally precise.
This is why I would summarize the difference this way:
Zustand manages state subscriptions. Coaction manages state dependencies.
That sentence is deliberately broader than “Coaction has cached getters.”
The getter is just the API surface. The graph is the feature.
Why Reselect does not make this distinction disappear
Reselect is mature, effective, and often exactly what a Zustand application should use.
But it solves the problem as an application-level memoization system.
That means the architecture becomes:
application
│
┌──────────┴──────────┐
│ │
Zustand store Reselect graph
│ │
└──────── subscribers ┘
With Coaction, the goal is closer to:
runtime
│
┌─────────┴─────────┐
│ │
source state computed state
│ │
└──── dependency ───┘
│
▼
observers
The first architecture is explicit and modular.
The second is more integrated.
There are good reasons to prefer either one.
But they are not the same abstraction merely because both can avoid an expensive recomputation.
Parameterized derived state exposes the difference even more
Coaction 4.0 does not provide computedFamily, Resource, or automatic keyed-cache eviction. The following describes a design problem applications can encounter, not an additional capability shipped by this release.
Suppose an editor repeatedly asks for:
getNodeBounds(nodeId);
Many components may need the same node’s bounds, while thousands of other nodes exist.
A good derived system wants something like:
nodeBounds('a') ──→ shared derived node A
nodeBounds('b') ──→ shared derived node B
nodeBounds('c') ──→ shared derived node C
Then:
node 'a' changes
│
└── invalidate nodeBounds('a')
not every bounds calculation.
In Zustand, this usually leads to selector factories, maps of memoized selectors, or another caching layer. Then cache lifecycle becomes part of your design:
- who creates a keyed selector;
- who owns it;
- when it can be reclaimed;
- how it is isolated between store instances;
- how SSR affects it;
- how parameters participate in memoization.
That is workable, but notice what happened: you are designing a reactive computation runtime around the store.
This is the ceiling I care about more than a few extra selector lines.
In 4.0, a derive instance can capture a node ID and track its reads, but the application still decides how to share instances by key, isolate them by store or SSR request, and dispose them. A new derive call creates a new instance; it does not automatically reuse another consumer’s cache. This part of the caching infrastructure remains application-owned in Coaction as well as Zustand.
What a migration actually deletes
The payoff is concentrated in a few kinds of code.
1. Most subscription-only selectors and useShallow
With render tracking:
const UserCard = observer(() => {
const store = useUser();
return (
<article>
<img src={store.user.avatar} />
<strong>{store.user.name}</strong>
<span>{store.user.status}</span>
</article>
);
});
You no longer need a second declaration such as:
useShallow((state) => ({
avatar: state.user.avatar,
name: state.user.name,
status: state.user.status,
}));
The render is already the dependency declaration.
This is especially valuable when component reads evolve frequently. Add one read, remove one read, or move a field, and there is no separate selector object to keep synchronized.
2. Store-derived useMemo
Coaction getters should replace only computations that genuinely belong to the state model:
- all inputs come from store state;
- multiple consumers may reuse the value;
- the relationship has domain meaning;
- caching or stable identity is useful.
For example:
get sortedItems() {
return [...this.items].sort((a, b) => b.score - a.score);
}
But this still belongs in the component:
function ProductList({ minimumPrice }: { minimumPrice: number }) {
const items = useCart((state) => state.items);
const visibleItems = useMemo(
() => items.filter((item) => item.price >= minimumPrice),
[items, minimumPrice],
);
// ...
}
minimumPrice is component input. Moving it into global state just to use a computed getter would couple component lifetime to the store.
So the accurate claim is not:
delete every
useMemo.
It is:
delete the
useMemocalls that are really re-implementing store-level computed state inside components.
3. A large amount of selector-specific memoization infrastructure
If a shared Reselect selector exists because several components need one domain-level derived value, a cached getter can often own that relationship directly.
This does not make Reselect obsolete. Parameterized projections, reusable library selectors, and some cross-store calculations can still be better expressed explicitly.
The change is that memoized selectors stop being the default way the application invents first-class derived state.
4. Immutable update boilerplate
Coaction’s draft path gives mutable syntax with immutable results:
set(() => {
this.count += 1;
this.items.push(item);
});
Writes still have a strict boundary.
This is invalid:
incrementWrong() {
this.count += 1;
}
and throws.
The valid form is:
increment() {
set(() => {
this.count += 1;
});
}
Native state is exposed through readonly views, and draft updates produce immutable state versions. Local non-plain objects are atomic identity leaves, not recursively observed mutable instances. set() remains the transition boundary for Coaction-owned data.
I consider this a useful third advantage, but not the central argument of this article. The central argument is still dependency ownership.
A subtle performance issue: fine-grained tracking is not free
Nested path tracking is powerful, but proxy traps have a cost.
For a selector that touches one small path, that precision is useful:
const name = useStore((state) => state.user.profile.name);
For a selector that scans every element of a large collection, per-element tracking may be wasted work because any relevant collection change will cause the scan to run again anyway.
Coaction exposes whole() for that case:
import { whole } from "@coaction/react";
const total = useCart((state) =>
whole(state.items).reduce((sum, item) => sum + item.price, 0),
);
whole(state.items) records a coarse dependency on the collection in this tracked selector and returns the underlying value for a read-only scan. Do not mutate that value. Any relevant collection update can invalidate the whole scan; the helper saves per-element tracking work, not the scan itself.
This is a good example of the trade-off in an automatic dependency runtime:
finer dependency knowledge can remove unnecessary invalidation, but dependency collection itself is work.
The correct granularity depends on the access pattern.
Writes have a tracking cost too. Leaving enablePatches unset does not guarantee that every write is patch-free: commit hooks, shared transport and some tracking paths request patches automatically. Eligible small dependency sets can compare observed values before and after a write without generating patches. Cached getter snapshots, patch middleware and larger read sets can still require the patch path.
“Isn’t this just MobX?”
This is the obvious question.
Getters, this, automatic tracking, and computed values are not new. MobX has had these ideas for a long time.
Coaction’s distinction is not that it invented reactive graphs.
The more interesting difference is the substrate.
| Coaction | Zustand | MobX | |
|---|---|---|---|
Function-style create() |
yes | yes | typically makeAutoObservable / observable setup |
| Render tracking without selectors | observer() |
no by default | observer() |
| Runtime cached derived values | yes | external / manual | yes |
| Immutable snapshots | yes | yes if updates are written immutably | no, mutable observables by default |
| Structural sharing | yes | depends on update path | not the primary model |
| Patch stream | built into Coaction’s immutable update model | external | use another layer such as MST if needed |
| Worker/shared-state direction | built into Coaction architecture | external | external |
MobX remains the much more mature reactive system.
If what you want is a battle-tested dependency graph and mutable observables fit your architecture, MobX is the safer choice today.
Coaction is interesting because it combines that style of dependency-aware runtime with immutable snapshots, structural sharing, patches, and a Zustand-like store API.
That combination is the point, not the existence of get value() syntax by itself.
Performance: read the benchmark for what it actually proves
Microbenchmarks are useful for exposing implementation differences. They are terrible when turned into slogans.
The following measurements were refreshed on 2026-09-11 using Coaction runtime and benchmark revision 12ae616, Apple M1 Max, Node 24.16.0, Mutative 1.3.0, alien-signals 3.1.2, Zustand 5.0.11 and Immer 11.1.4. The complete report and raw results include every case, sample count, error estimate and reproduction command.
These are JavaScript workloads without React rendering, history, remote synchronization or transport. The stable-read case reads the derived total of a 1,000-item cart repeatedly:
| Pattern | ops/sec | Relative |
|---|---|---|
| Coaction cached accessor getter | 66,109,753 | 1.000x |
| Coaction computed with manual deps | 33,809,569 | 0.511x |
| Zustand selector recompute | 787,472 | 0.012x |
| Zustand maintained total field | 112,062,542 | 1.695x |
In this run the cached getter is about 84 times as fast as the selector that recalculates the total on every invocation.
But this table must not be interpreted as:
Coaction is 84x faster than a well-designed Zustand + Reselect application.
A shared memoized selector can also turn repeated reads into cache hits.
That is exactly why Reselect belongs in this comparison.
The benchmark proves the value of caching relative to recomputation. The architectural argument is that Coaction makes that caching and invalidation a store primitive instead of an application-managed selector layer.
Those are different claims.
Updating, then reading
The same script changes one item and then reads the derived total on every operation:
| Pattern | ops/sec | Relative |
|---|---|---|
| Coaction mutable update + cached getter | 48,480 | 1.000x |
| Coaction mutable update + manual deps | 48,318 | 0.997x |
| Coaction object replacement + cached getter | 169 | 0.003x |
| Zustand immutable update + selector recompute | 88,113 | 1.818x |
| Zustand immutable update + maintained total | 2,897,112 | 59.759x |
Here the Coaction draft-plus-getter case reaches about 55% of the Zustand update-plus-selector case. The maintained-field approach is faster because the action adjusts the total incrementally instead of scanning the array again. This is an update-plus-read comparison, not a pure-write or React-render comparison.
The replacement row includes public proxy reads, detaching incoming containers and snapshot work. It does not isolate Mutative, nor does it show that every part of the difference is unavoidable. High-frequency drag loops, animation state and streaming updates need their own measurements.
Read-heavy product UIs and write-heavy simulation loops are different workloads.
Bulk immutable updates
The legacy bulk suite starts each case with a 50,000-object array (50 numeric fields per object) and a 1,000-key record, then repeatedly appends to the array and writes a record entry. The record is not a JavaScript Map. The array grows during measurement, so this is not a fixed-size leaf-update benchmark.
| Pattern | ops/sec | Relative |
|---|---|---|
| Coaction | 0.72 | 0.000x |
| Coaction with Mutative | 4,061 | 1.000x |
| Zustand | 5,821 | 1.433x |
| Zustand with Immer | 277 | 0.068x |
All four paths are shown, including Coaction’s expensive object-replacement case. The draft paths retain unchanged subtrees, while submitting the entire large array as an object payload exercises a much larger ownership boundary.
The Coaction-with-Mutative row had ±21.96% relative uncertainty in this run. That makes its smaller difference from plain Zustand unsuitable as a stable ranking. The measured Immer path is slower here, but the result does not rank every draft workload or describe React frame rate.
The audit ran the existing benchmark setup and timing code with only the trailing chart download omitted. Reproduce the suite with pnpm benchmark; use pnpm benchmark:check and representative fixed workloads for regression decisions. CPU, engine, dependency versions, data shape and observation mode all matter.
One migration trap matters more than the others: large set({ ... }) payloads
Zustand muscle memory often looks like this:
bump(index) {
const items = get().items.slice();
items[index] = {
...items[index],
quantity: items[index].quantity + 1
};
set({ items });
}
That call is semantically valid in Coaction, but large object payloads take a more expensive path. Incoming plain containers are sanitized and detached from caller-owned references; cycles and aliases within the payload can be retained, and non-plain atomic leaves keep their identities. Reading the public proxies and building committed snapshots add work too. Earlier versions also deep-copied the untouched current root; that defect is fixed and should not be presented as an unavoidable 4.0 cost.
The Coaction-native form is:
bump(index) {
set((draft) => {
draft.items[index].quantity += 1;
});
}
For large arrays and objects, this distinction is important.
A useful migration sweep is:
grep -rEn "set\(\s*\{" src
Then inspect the size of the value written at each call site.
For a scalar, the difference may be irrelevant. For a large list or lookup table, prefer the draft path.
Another migration trap: not every computation belongs in the store
The existence of cached getters can tempt you to globalize inputs that should remain local.
Do not move props, route params, transient input, or component-local state into a global store only to make a computation eligible for a getter.
A simple rule works well:
If every input belongs to the domain state and the result is reusable, it is a good computed candidate.
If an input belongs to one component instance, keep the computation close to that component.
A reactive runtime should reduce accidental infrastructure, not erase architectural boundaries.
Zustand or Coaction?
Both can handle most ordinary state-management work.
The difference is mainly who carries the complexity.
| Better suited to Zustand | Better suited to Coaction |
|---|---|
| Small stores, few selectors, little shared derived state | Selectors, caches, and shared derived state are already everywhere |
| Bundle minimalism comes first | Willing to pay for a richer dependency runtime |
| Team strongly prefers explicit subscription mechanics | Comfortable with read tracking and computed invalidation |
| Write-heavy hot paths dominate | Read-heavy product surfaces dominate |
| Existing selector architecture is already clean and stable | Selector/memoization infrastructure is becoming a maintenance layer |
| Ecosystem maturity is the highest priority | Immutable snapshots, patches, workers, or shared-state architecture matter |
If selectors show up only occasionally, they are a clear and explicit design.
If a handful of computations are local and cheap, plain functions are enough.
Zustand should not be replaced merely because another runtime can do more.
The crossover happens when the application starts repeatedly solving the same two dependency problems:
Who needs to react?
What needs to recompute?
At that point, selectors and memoized derivations are no longer just implementation details. They are an application-owned reactive layer.
That is the layer Coaction is trying to absorb.
Workers are the architectural ceiling, not the entry point
Coaction also has a path toward Web Workers, SharedWorkers, cross-tab state, and collaborative synchronization.
That is not required to justify using it in a single-threaded application.
The single-thread argument should stand on its own:
state
│
├── dependency-aware observation
│
└── dependency-aware computation
Shared mode then extends the same state model across a different ownership boundary.
In 4.0 the default coaction and @coaction/react entries contain the local runtime and exclude shared transport code. Switch create to coaction/shared or @coaction/react/shared when introducing worker or transport options; passing those options to the local entry throws. See the 4.0 migration guide.
That matters because a state abstraction with a richer runtime can grow into:
main thread
│
├── observer graph
│
└──── shared state protocol ──── worker / SharedWorker
│
└── authoritative computation
But that is an architectural ceiling, not a free toggle.
Cross-thread state changes method calling, serialization constraints, ordering, error handling, and authority. It should be adopted because the workload needs it, not because the library happens to support it.
Shared client actions return promises and values crossing the transport must satisfy the strict JSON contract. A client reads its local mirror synchronously. Remote server synchronization through @coaction/sync is a separate durable-outbox integration, while Yjs provides a CRDT integration; neither is implied by creating a Worker-backed store.
Closing
I do not think the strongest reason to migrate from Zustand to Coaction is draft syntax, this, or even fewer selector lines.
Those are visible features.
The deeper reason is that sufficiently large Zustand applications often accumulate two application-managed systems around the store:
observation policy
└── selectors, equality, useShallow
computation policy
└── useMemo, Reselect, memoization, cache ownership
Coaction tries to make both part of the runtime:
reactive runtime
│
┌────────────┴────────────┐
│ │
observation graph computation graph
│ │
who reacts? what recomputes?
That is why I think “no selectors” is too shallow a description.
And “cached getters” is too shallow a description too.
The actual distinction is:
The runtime understands dependencies.
For observation, that means a consumer’s reads define what it depends on.
For derived state, that means one computation can become one shared, cached node in the runtime and feed any number of consumers or other computed nodes.
Zustand can assemble both capabilities. Reselect, proxy-memoize, tracking libraries, computed middleware, and disciplined store architecture are all valid tools.
But the application owns that assembly.
Coaction’s bet is that once those relationships become pervasive, they belong below the application layer.
So my recommendation remains intentionally narrow:
- if your Zustand project is clear, small, and stable, stay on Zustand;
- if you mainly want mature mutable reactivity, use MobX;
- if selectors and shared derivations have become a second body of infrastructure and you also value immutable snapshots and patches, Coaction becomes an interesting alternative.
The migration is not really about deleting a few lines of selector code.
It is about deciding who should own the dependency graph.
npm install coaction @coaction/react
References and reproducibility
- Coaction repository: https://github.com/coactionjs/coaction
- Coaction documentation: https://coactionjs.github.io/coaction/en/
- Coaction vs Zustand: https://github.com/coactionjs/coaction/blob/main/docs/comparison/zustand.md
- Why Coaction Without Multithreading: https://github.com/coactionjs/coaction/blob/main/docs/comparison/single-thread.md
- Migrating from Zustand: https://github.com/coactionjs/coaction/blob/main/docs/migration/from-zustand.md
- Zustand documentation: https://zustand.docs.pmnd.rs/
- Reselect: https://reselect.js.org/
- proxy-memoize: https://github.com/dai-shi/proxy-memoize
- MobX computeds: https://mobx.js.org/computeds.html
- 4.0 migration: https://coactionjs.github.io/coaction/en/docs/guides/migrating-to-v4
- Computed boundaries: https://coactionjs.github.io/coaction/en/docs/concepts/computed
- Pinned measurements and raw results: https://github.com/coactionjs/coaction/blob/main/docs/benchmarking/4.0-measurements.md
- Zustand hook implementation: https://github.com/pmndrs/zustand/blob/main/src/react.ts
- Derived-state benchmark:
pnpm benchmark:zustand-positioning - Bulk update benchmark:
pnpm benchmark
Disclosure: I am the author and maintainer of Coaction. This is an opinionated architectural comparison, not an independent third-party review. The claims are intentionally framed around trade-offs rather than feature count; challenges are welcome, especially when they come with reproducible code and workload-specific measurements.