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. To this day, if a project needs a handful of simple hook stores, Zustand is still what I recommend.

But as a codebase grows, the same chores keep coming back:

  • designing a selector for each component;
  • keeping composed selectors referentially stable;
  • caching derived values that several components need;
  • keeping “the fields a component reads” and “the fields a component subscribes to” in sync;
  • updating selectors and dependency arrays every time the domain model shifts.

None of these is hard on its own.

The problem is that once selectors, caches, and derived relationships are spread across a codebase, they quietly become infrastructure you have to maintain.

First, let’s put a number on “large”

“It stops scaling once the project gets big” is empty unless you say how big. Here is the typical shape of a ~100k-line frontend application, which anchors every trade-off discussed below:

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 any one migration. It is derived from 150–250 LOC per file, components at 55–70% of files, 35–50% of components reading store state, and 1.5–3 selector call sites per consuming component. Its purpose is to give you something to compare your own counts against. Every performance number later in this article is measured, not estimated, and every one of them is reproducible from a script.

You can count your own project 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, the premise of this article does not hold for you — stay on Zustand. The codebases that get crushed by this complexity are the ones with selector counts in the hundreds and derived relationships reused across many components.

At that scale, Coaction‘s trade-offs start to make sense. It keeps a Zustand-like create API but moves three things into the runtime:

  1. Render tracking — automatically tracks the state a component reads while rendering;
  2. Cached computed state — automatically memoizes values derived from store state;
  3. Draft updates — mutable syntax, immutable results.

The migration payoff concentrates in three kinds of code: most selectors, nearly all useShallow calls, and the useMemo calls that exist purely to derive store state.

Note “most” and “nearly all”, not “everything” — the boundaries are spelled out below.


Being fair first: for simple cases, 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>
  );
}

No useShallow here, no useMemo, and no need to reach for useCounter.getState().increment() to call an action.

Selecting count, step, and increment separately is idiomatic Zustand. And a computation as cheap as count * 2 has no business being wrapped in useMemo.

So if your store is small and components read only a few fields, Zustand imposes no meaningful “subscription tax”. In that situation, adopting a fuller automatic-tracking runtime may not be worth it.


The difference isn’t fewer lines — it’s that 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 (
    // increment is already bound to the store; pass it directly, no arrow wrapper
    <button onClick={store.increment}>
      {store.count} (step {store.step}) → {store.doubled}
    </button>
  );
});

For an example this small, saving a few lines is not a decisive advantage.

The real difference is this:

In Zustand, “which fields you read” and “which fields you subscribe to” are matched up by hand through a selector. In Coaction, the fields a component reads while rendering are the fields it subscribes to.

In short: reads are subscriptions.

When a component reads one or two fields, the distinction barely matters. It starts to matter when read relationships change often and shared derived state accumulates.


Where the gap actually opens: complex derived state

Consider a shopping cart with an item list, a filter, a discount rate, the filtered items, and the discounted total.

In Zustand you might organize it like this:

import { useMemo } from "react";
import { create } from "zustand";
import { useShallow } from "zustand/react/shallow";

type CartItem = {
  id: string;
  price: number;
  quantity: number;
  selected: boolean;
};

type CartStore = {
  items: CartItem[];
  filter: "all" | "selected";
  discountRate: number;
};

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

The composed selector here is one common style, not Zustand’s only option. You could select the three fields separately, or lift the derived logic into a shared memoized selector.

The component-level version above is meant to illustrate a single point: when derived logic lives at the consumer, the application has to maintain the subscriptions, dependencies, and caches itself.

The issue was never that Zustand cannot do this. It is that these policies are the application’s job:

  • where derived values should live;
  • whether they need caching;
  • how several components reuse the same derived logic;
  • which equality function to use when a selector returns an object;
  • which selectors and dependency arrays must change when the state dependencies change.

Zustand’s model is explicit and flexible. But explicit means your team designs and maintains those policies.


Coaction: putting derived relationships back into the state model

The same cart:

import { create, observer } from "@coaction/react";

type CartItem = {
  id: string;
  price: number;
  quantity: number;
  selected: boolean;
};

type CartFilter = "all" | "selected";

const useCart = create((set) => ({
  items: [] as CartItem[],
  filter: "all" as CartFilter,
  discountRate: 0,

  get visibleItems() {
    return this.filter === "selected"
      ? this.items.filter((item) => item.selected)
      : this.items;
  },

  get total() {
    const subtotal = this.visibleItems.reduce(
      (sum, item) => sum + item.price * item.quantity,
      0,
    );

    return subtotal * (1 - this.discountRate);
  },

  addItem(item: CartItem) {
    set(() => {
      this.items.push(item);
    });
  },
}));

const CartSummary = observer(() => {
  const store = useCart();

  return (
    <section>
      <p>Items: {store.visibleItems.length}</p>
      <p>Total: {store.total}</p>
    </section>
  );
});

The derived relationships are declared in the store itself:

  • visibleItems depends on items and filter;
  • total depends on visibleItems and discountRatea getter may read another getter, and the cache chain links up automatically;
  • the derived logic is defined once and reused by any number of consumers.

It is closer to a built-in, store-level computed system.

Coaction does not suddenly make possible something Zustand could not do. What it does is push the subscription, caching, and derivation policies — the ones your application used to organize by hand — down into the runtime.


What the migration deletes

1. Most selectors and useShallow

Inside a component wrapped in observer(), the fields read during render subscribe automatically:

const UserName = observer(() => {
  const store = useUser();

  return <span>{store.name}</span>;
});

A component reads exactly what it needs. The render code is the subscription declaration.

So there is no longer a need to maintain:

const name = useUser((state) => state.name);

or:

useShallow((state) => ({
  name: state.name,
  avatar: state.avatar,
  status: state.status,
}));

But automatic tracking is not unbounded property-level tracking. The pitfalls section below demonstrates this with measurements — it is the part of a migration that requires the most code changes.

When you want explicit control, the selector-style API is still there. Automatic tracking is the default, not a cage.

2. useMemo that only derives store state

Coaction’s getters do not replace every useMemo. They replace this specific kind of computation:

  • depends only on store state;
  • is reused across several components;
  • expresses a relationship that belongs to the state model;
  • is expensive enough, or needs a stable result.
get sortedItems() {
  return [...this.items].sort((a, b) => b.score - a.score);
}

Two things distinguish it from a useMemo in a component: the derived logic lives in the store rather than scattered across component instances, and the dependencies come from the state reads that happen while the getter runs, so there is no second dependency array to keep in sync.

But if a computation depends on props, local state, or transient input, it does not belong in the store:

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; it should not necessarily become global state.

So what you can delete is not every useMemo, but:

the ones that are really re-implementing a store computed inside a component.

3. Object spreads and Immer boilerplate

The draft update form lets you describe an update with mutable syntax:

set(() => {
  this.count += 1;
  this.items.push(item);
});

That path produces an immutable result through Mutative.

But Coaction does not let you mutate state anywhere you like. Writes must happen inside set():

const useCounter = create((set) => ({
  count: 0,

  incrementWrong() {
    this.count += 1; // throws
  },

  increment() {
    set(() => {
      this.count += 1;
    });
  },
}));

The error is explicit:

Direct state mutation is not allowed in immutable Coaction stores.
Wrap mutations in set(() => { ... }).

State updates therefore keep a clear boundary. set() is the single entry point that produces the next state and notifies subscribers. Compared to “a reactive object you can mutate from anywhere”, that constraint makes update sources easier to locate and easier to debug.

4. Manually preserving this, and manually selecting actions

In Zustand, actions are ordinary fields on the store. To use one in a component you normally select it:

const increment = useCounter((state) => state.increment);

or grab the latest one inside an event handler:

onClick={() => useCounter.getState().increment()}

In Coaction, methods are bound to the store or slice they belong to, so you can pass them directly:

<button onClick={store.increment}>+1</button>

Even destructured out of getState(), this survives:

const { increment } = useStore.getState().counter;
increment(); // still correct; this still points at that slice

For a single counter this is barely visible. But as the number of actions grows and cross-slice calls multiply, “never hand-preserving this, never writing a selection for each action” steadily removes one class of boilerplate and one class of bug.


“Isn’t this just MobX?”

This is the question I expect most, so let me answer it directly.

Getters, this, automatic tracking, cached computeds — none of this is new. MobX has shipped it for a decade, and Pinia gives Vue the same shape. I am not going to pretend this is an invention.

The difference is the substrate. Both points below were measured against mobx@6.15:

First, MobX computeds are cached only while observed.

MobX memoizes a computed only while a reaction observes it. Read the same getter four times outside observer() / autorun() — in an event handler, a test, during SSR — and it evaluates four times. Coaction’s getters are backed by alien-signals computeds and cache unconditionally; the same four reads evaluate once.

Second, MobX is a mutable observable; Coaction is an immutable snapshot.

MobX mutates in place, so a reference you captured earlier changes underneath you. Coaction’s public state is frozen and structurally shared.

The second point is the important one. Because every update produces an immutable result, Coaction has a patch stream — and undo/redo, persistence, cross-thread transport, and CRDT collaboration are all built on it. mobx-state-tree buys snapshots and patches back, at the cost of a second type system.

Coaction Zustand MobX
Function-style create(), no decorators yes yes makeAutoObservable
Render tracking without selectors observer() no observer()
get value() + this yes no yes
Derived values memoized always no only while observed
Frozen snapshots, structural sharing yes via Immer/Mutative no — mutable observables
Patch stream (undo / persist / sync / CRDT) built in no mobx-state-tree
Entry size (gzip, deps excluded) 11.1 KB coaction/local 0.6 KB vanilla+react 16.4 KB

Read that last row carefully: Zustand really is an order of magnitude smaller, and that is the trade being made. Coaction’s 11.1 KB excludes mutative (~6.7 KB) and alien-signals; MobX’s 16.4 KB is self-contained.

If what you want is a mature, battle-tested reactive graph and mutable observables suit you, MobX is the safer choice — its ecosystem and track record are far deeper than Coaction’s.


Performance: one set of numbers supports me, two do not

All of these are reproducible from the repository. Regenerate before quoting them — microbenchmarks move with CPU, runtime, and package versions. The numbers below come from a single run on an Apple M1 Max, Node 24.16, zustand@5.0.11.

Reading derived state — pnpm benchmark:zustand-positioning

Pattern ops/sec Relative
Coaction cached getter 76,726,880 1.00x
Coaction get(deps, selector) 49,119,723 0.64x
Zustand selector that recomputes 796,100 0.010x
Zustand manually maintained total field 112,175,343 1.46x

Against the pattern most codebases actually write — a selector that recomputes derived data — a cached getter is roughly 96x faster.

The only thing faster is a derived field the application maintains by hand inside every action, which is precisely what this article has been arguing against. The numbers state the trade plainly:

You can be 1.46x faster and maintain consistency by hand forever, or give up 32% of read throughput and delete that entire class of bug.

Updating, then reading — same script

Pattern ops/sec Relative
Coaction draft update + cached getter 43,918 1.00x
Coaction draft update + get(deps, selector) 43,660 0.99x
Coaction object replacement + cached getter 342 0.008x
Zustand immutable update + selector recompute 88,935 2.03x
Zustand immutable update + maintained field 3,302,630 75.2x

Coaction loses this one. Its write path is about 2x behind a plain Zustand update, and much further behind a hand-maintained field.

Read-heavy interfaces — most product surfaces — absorb that easily. High-frequency drag, animation frames, and streaming tick handlers do not. I have to state this, because it is also written into the project’s own README and its “when not to use Coaction” guide.

The third row mixes two separate things: a trade-off and a defect. The next section pulls them apart — one of them I have already fixed.

Bulk update throughput — pnpm benchmark

State holds a 50,000-object array and a 1,000-entry map; each operation appends one of each:

Update path ops/sec Relative
Coaction — Mutative draft 4,648 1.00x
Zustand — object replacement 5,886 1.27x
Zustand — Immer draft 298 0.06x

The draft path is ~21% behind Zustand’s object replacement and ~15.6x ahead of Immer.

Coaction’s own object-replacement path is deliberately absent from this table. At this state size it is dominated by the payload sanitizer, so it measures the guarantee rather than the update mechanism. The next section explains why.

Beyond the three benchmarks: I found and fixed a bug while writing this

While preparing the table above I re-ran pnpm benchmark, and the result did not match the snapshot that had been sitting in the README for a long time. Digging in, the set({ ... }) path turned out to stack two different things — one a genuine defect, one a guarantee I added on purpose. Taken separately:

One: copying the current state was a deep clone (a real defect, now fixed)

On an object-payload commit, the code first copies the current root state, and that copy helper ran every value through a deeply recursive replacement sanitizer. The result: every set({ ... }) was O(total state) — change one scalar and untouched fields were re-cloned in full anyway.

Replacing an unrelated scalar in a store that merely holds a 10,000-item array, 400 operations:

Before After
set({ counter }) 1,591 ms 2 ms

The store already owns and already sanitized its current state, so re-sanitizing it on every commit was redundant. Switching to a shallow copy of own keys — nested values keep their identity — brings this path back into the same order of magnitude as the draft path. Incoming payloads are unaffected, which brings us to the second item.

Two: incoming payloads are deep-sanitized (not a defect — a guarantee)

Whatever you hand to set({ ... }) is caller-supplied data. It is deep-cloned to strip unsafe keys and to break aliasing with objects the caller still holds. So passing in a fresh 1,000-element array costs O(payload):

Update path ops/sec
set((draft) => { ... }) 43,918
set({ items }) 342

This one cannot be optimized away without giving up the guarantee. Coaction 1.5.0 looks dramatically faster on this benchmark only because it did not make that guarantee — the sanitizer arrived in 2.0.0. I initially conflated the two and assumed the whole ~1,100x gap was a regression. That was my mistake.

Why nobody noticed

This repository has a mechanical gate for almost everything, but benchmark-regression-thresholds.json had no entry covering the object-payload path at all, and the regression suite never ran it. So pnpm benchmark:check stayed green throughout.

Both paths are now gated: Coaction unrelated field replacement guards the fixed clone (1,053,858 ops/sec today, floor 400,000), and Coaction object replacement + cached getter guards the payload sanitizer so it cannot silently get worse.

Takeaway for anyone migrating

Whenever the value you are writing is large, use set((draft) => { ... }). The draft path lets Mutative report precise patches, so neither the payload walk nor the cached getter’s snapshot rebuild is needed.

I left this section in rather than quietly fixing it before publishing, because it makes the earlier point concrete: microbenchmarks are good at surfacing implementation differences. This time the difference it surfaced was mine — and it corrected my own first diagnosis along the way.

All three of these are microbenchmarks. They are good for finding implementation differences, but they do not replace testing against a real workload, and they cover none of component-tree re-rendering, dependency-collection cost, subscription memory, or cross-thread communication.


Pitfalls you will hit during the migration

Pitfall 1: tracking stops at the top-level field boundary (the one that changes the most code)

“Reads are subscriptions” sounds like property-level tracking. It is not. Tracking happens at the field boundary of a store or slice.

Verified with a minimal reproduction:

const useStore = create((set) => ({
  user: { name: 'Ada', email: 'a@x.com' },
  unrelated: 0,
  setEmail(v) { set(() => { this.user.email = v; }); },
  setUnrelated(v) { set(() => { this.unrelated = v; }); }
}));

const View = observer(() => {
  const s = useStore();
  return <span>{s.user.name}</span>; // reads name only
});

Results:

Action Component re-renders?
Change user.email (sibling field, same object) Yes ← the pitfall
Change unrelated (unrelated top-level field) No

The second row shows tracking is working. The first shows its granularity is the user field, not user.name.

Two fixes, both verified:

// Fix 1: flatten hot fields to the top level
{ userName: 'Ada', userEmail: 'a@x.com' }

// Fix 2: split by change frequency into slices
{
  name: () => ({ value: 'Ada' }),
  contact: (set) => ({ email: 'a@x.com', setEmail(v) { /* ... */ } })
}

After either change, updating the email no longer re-renders the component that reads only the name.

Conclusion: migrating to Coaction does not make state modeling less important. It moves the modeling problem from “designing selectors” to “designing field boundaries.” If your stores hold large object fields whose inner fields change at very different rates, this is the bulk of the migration work.

You can audit for it before migrating — a field is a re-render source if all three hold:

  1. it is an object or array, not a scalar;
  2. its sub-fields change at very different rates (say, user.lastActiveAt every second while user.name almost never changes);
  3. some component reads only a small part of it.

The classic high-risk shape is the catch-all field: user, settings, session, meta. These usually exist in the Zustand version too — they were just masked by the selector’s hand-tuned granularity. useStore(s => s.user.name) plus Object.is happens to block sibling changes; observer()‘s field boundary does not. In other words this is not a new problem Coaction introduces; it makes modeling debt that was hidden inside selectors explicit.

One counterintuitive but correct behavior: destructuring during render does not break tracking.

const { count } = useStore(); // still tracks count correctly

because destructuring is itself a read.

Pitfall 2: not every useMemo can move into the store

Mentioned earlier, but you only hit it concretely during a migration. The test is simple — if even one input to the computation does not belong to the store, it stays in the component: props, local state, route params, useContext values all count.

The tempting mistake is: “if I put the props into the store too, I could use a getter.” Don’t. That couples component lifecycle to global state — the input outlives the component after unmount, and multiple mounted instances overwrite each other. Leaving these computations in useMemo is correct, not a compromise.

Using the earlier estimate, a 100k-line application has ~260 useMemo uses, of which roughly 85 are purely store-derived and can move; the other ~175 belong in components anyway. “Delete every useMemo” was never the goal.

Pitfall 3: the set() boundary throws in a burst mid-migration

Code migrated from Zustand contains places that write state directly — especially after pulling an object out of getState() and assigning to a field. In Coaction these throw instead of silently taking effect.

That is a good thing: the error message is direct, and those writes were bugs already. But be prepared — these errors arrive in a burst mid-migration, not sprinkled thinly.

You can find them before you start:

# suspicious direct writes to fields pulled out of getState()
grep -rEn "getState\(\)\.[A-Za-z_$][A-Za-z0-9_$]*\s*(=[^=]|\+\+|--|\.push\(|\.splice\()" src

Worth noting: these are equally wrong in Zustand — Zustand just does not stop them. So this batch of errors usually contains some pre-existing state corruption that had never been noticed.

Pitfall 4: no cached getters on mutable external adapters

If you bind an existing store through a mutable adapter such as @coaction/mobx or @coaction/pinia, cached getters and get(deps, selector) are unavailable on those instances. observer() still receives updates, but subject to the same field-boundary granularity.

This is documented explicitly in the project docs; check whether you are on that path before migrating.

Pitfall 5: Zustand muscle memory walks straight into set({ ... })

This one is migration-specific, and the one I think is most worth knowing in advance.

set({ items }) is simply how you write Zustand. Coaction’s set() accepts the same object payload — semantically correct, and correctly typed — so code carried over from Zustand runs unchanged. It is just two orders of magnitude slower at this scale, with nothing to warn you.

// carried over from Zustand: works, but takes the slow path
bump(index) {
  const items = get().items.slice();
  items[index] = { ...items[index], quantity: items[index].quantity + 1 };
  set({ items });
}

// how it should be written in Coaction
bump(index) {
  set((draft) => {
    draft.items[index].quantity += 1;
  });
}

After the rewrite, the same operation goes from ~340 ops/sec to 43,918 ops/sec.

Note this is distinct from the defect fixed in the previous section: that one is fixed, this one is the intrinsic cost of the payload sanitizer, and only a rewrite avoids it.

This is the single most valuable thing to sweep for during a migration: every set({ ... }) write whose target field is a sizable array or object should become a draft update. Nothing throws, tests stay green, and only performance degrades — which makes it the hardest kind of problem to catch in review.

Find all the candidates:

# object-payload set() calls; judge each by the size of the target field
grep -rEn "set\(\s*\{" src

The criterion is not how many there are but how large the value written at each site is. For a scalar or a handful of keys, the two forms are indistinguishable; for arrays, lists, and lookup tables, the draft form is required. Sweep them once during the migration rather than hunting them down after production slows.

Zustand or Coaction?

Both handle most state-management work. The difference is mainly who carries the complexity: the application code, or the state runtime.

Better suited to Zustand Better suited to Coaction
Small stores, few selectors, little derived state Selectors, caches, and shared derived state are already everywhere
Minimal dependencies and bundle size come first Willing to pay ~10x entry size for a built-in runtime
Write-heavy hot paths (drag, animation, live streams) Read-heavy product surfaces, with draft updates used consistently
Team prefers explicit subscriptions over implicit tracking Comfortable with “reads are subscriptions”, and with investing modeling effort in field boundaries
Maturity and ecosystem depth matter most Worker, SharedWorker, or collaboration is on the roadmap

If selectors show up only occasionally, they are a clear, explicit design.

It is when selectors, caches, and derived relationships are spread across the whole project that they turn into infrastructure you have to maintain. That is what Coaction is offering to take over.


Workers are the architectural ceiling, not a free upgrade

Coaction also offers a path toward Web Workers, SharedWorkers, cross-tab state, and Yjs collaboration.

None of that is a prerequisite for single-threaded use. You can adopt only automatic tracking, computeds, and draft updates.

But shared mode is not a zero-cost switch:

  • the client holds a mirror of the authoritative state;
  • methods execute across a thread boundary and return promises;
  • state ownership, call ordering, and the error-handling model all change;
  • everything crossing the boundary must be JSON-shaped — no Date, no Map, no class instances.

So the accurate claim is not “flip one option and you get multithreading.” It is:

The same state model has a path to evolve into multithreaded and collaborative scenarios, without replacing your state abstraction from scratch.

That is an architectural ceiling, not a free implementation detail.


Closing

The reason to migrate from Zustand to Coaction is not that Zustand is inadequate, nor that Coaction is faster on every axis — it is clearly slower on write-heavy paths, and while writing this article I found a performance defect in my own object-replacement path that had survived two major versions.

The only reason that holds is this: selectors, caches, and derived relationships have grown into a second body of state infrastructure that you now maintain. Go back to the table at the top — if you counted only a few dozen selectors, that reason does not apply to you.

Zustand keeps its core simple and hands subscription and derivation policy to the application. Coaction uses a fuller runtime to take over automatic tracking, cached computeds, immutable drafts, and a path into shared mode.

Both are reasonable choices.

If your Zustand project is still clear, stable, and simple, stay on Zustand. If what you want is getters plus this plus automatic tracking, and mutable observables are fine for you, MobX is more mature.

Coaction occupies a narrow position: you want that developer experience and you need what immutable snapshots and a patch stream make possible — undo, persistence, cross-thread state, collaboration.

What it is really offering to take over is not a few lines of selector, but the complexity behind them.

npm install coaction @coaction/react

Reproducing everything here

Disclosure: I am the author and maintainer of Coaction. This is an opinionated analysis, not an independent third-party comparison. Challenges to the implementation, the boundaries, or the benchmarks are welcome — ideally with reproducible code and a test scenario.