Home / Articles / Why Redux or Zustand Belong With Many Writers, Not Many Readers

This article is published in English.

Why Redux or Zustand Belong With Many Writers, Not Many Readers

Reader count is not enough reason for a client store. Multiple writers sharing invariants — like a shopping cart — are when reducers, replay, and direct tests earn their keep.

2370 words

Four common justifications for pulling in Redux, Zustand, or similar libraries were dismantled earlier: prop drilling, client-side updates, automatic fan-out to every subscriber, and Context as a safer default. React already covers those cases without an extra store.

None of those arguments asked the question that actually decides the library. Not how many components read a value. How many unrelated places can change it, and whether those changes must stay consistent with one another afterward.

A theme toggle has a single writer — one control, one setTheme call. That is why a library was unnecessary there no matter how many screens consumed the result. A shopping cart is a different shape: multiple writers pull in different directions, and that needs a different example.

One Writer Versus Many

A cart appears on a product card as “Add to Cart,” in a drawer as a quantity stepper, as a remove link, as a coupon field that recalculates totals, and as a clear-all control. Five files, five mutation sites, each able to alter the same state, none of them aware of the other four.

Contrast the theme flag: one button, one writer. Every reader simply displays the last value. No coordination, because only one hand turns the wheel.

The cart carries invariants — three of them. An invariant is a fact that must remain true after any operation. For this cart: the total always equals the sum of line-item price times quantity, minus the active discount; quantity never goes negative; two line items never share the same SKU — adding another unit of an existing product raises quantity instead of inserting a duplicate row.

Five writers, three facts every writer must preserve. That is the problem Redux, Zustand, or MobX exist to solve, and it has nothing to do with how many components only read the cart.

What goes wrong when five independent writers touch the same three facts with nothing enforcing them?

What Breaks Without One Disciplined Place

Imagine three of those five mutation sites, each authored in the style its file owner would choose, mutating the cart object directly:

// components/AddToCartButton.jsx
function addItem(item) {
  cart.items.push(item);
  cart.total = cart.items.reduce((sum, i) => sum + i.price * i.quantity, 0);
}
// components/QuantityStepper.jsx
function changeQuantity(sku, newQty) {
  const item = cart.items.find(i => i.sku === sku);
  item.quantity = newQty;
  cart.total = cart.items.reduce((sum, i) => sum + i.price * i.quantity, 0);
}
// components/CouponInput.jsx
function applyCoupon(code) {
  cart.discount = getDiscountFor(code);
}

getDiscountFor is a plain lookup: code in, discount number out. AddToCartButton.jsx and QuantityStepper.jsx both recomputed cart.total. CouponInput.jsx did not. The invariant “total equals sum minus discount” broke, and the language did nothing — nothing owned the rule. It was a convention three files were trusted to follow; one forgot.

A reducer removes that trust by forcing every mutation through one function. A reducer takes current state and an action (a plain object describing what happened) and returns brand-new state. It never mutates the old object in place.

// store/cartReducer.js
function computeTotal(items, discount) {
  const subtotal = items.reduce((sum, i) => sum + i.price * i.quantity, 0);
  return subtotal * (1 - discount);
}

export function cartReducer(state, action) {
  switch (action.type) {
    case 'ADD_ITEM': {
      const items = [...state.items, action.item];
      return { ...state, items, total: computeTotal(items, state.discount) };
    }
    case 'CHANGE_QUANTITY': {
      const items = state.items.map(i =>
        i.sku === action.sku ? { ...i, quantity: action.qty } : i
      );
      return { ...state, items, total: computeTotal(items, state.discount) };
    }
    case 'APPLY_COUPON': {
      const discount = getDiscountFor(action.code);
      return { ...state, discount, total: computeTotal(state.items, discount) };
    }
  }
}

computeTotal runs inside every switch branch — not because each author remembered it, but because one function and one switch make skipping awkward. The three former direct writers now describe events instead of performing them:

// components/AddToCartButton.jsx
import { useCartDispatch } from '../store/CartContext';

function AddToCartButton({ item }) {
  const dispatch = useCartDispatch();
  return <button onClick={() => dispatch({ type: 'ADD_ITEM', item })}>Add to Cart</button>;
}
export default AddToCartButton;
// components/QuantityStepper.jsx
import { useCartDispatch } from '../store/CartContext';

function QuantityStepper({ sku, qty }) {
  const dispatch = useCartDispatch();
  return (
    <input
      type="number"
      value={qty}
      onChange={e => dispatch({ type: 'CHANGE_QUANTITY', sku, qty: Number(e.target.value) })}
    />
  );
}
export default QuantityStepper;

dispatch comes from a small CartContext.jsx that pairs useReducer (the built-in hook returning state plus dispatch) with Context so any component can reach that dispatch. Neither button nor stepper writes cart.total = ... anymore. Neither needs to know computeTotal exists.

Reads stay open. Components may still read cart.total for price displays, checkout summaries, or unsaved-item toasts. Only writes funnel through one path: dispatch an action, let the reducer produce the next state. The invariant lives in that choke point instead of in whoever happens to call it.

Be precise: a single write function does not make the rule correct — it makes enforcement consistent. If computeTotal itself forgot the discount, every write path would produce the same wrong total every time. That is still better than the scattered version: a uniform bug is easy to isolate. A bug that appears only when one file forgets a line is much harder to find, because correct and incorrect paths look identical until you hit the missing case.

When the coupon path forgets the recomputation, the UI may still look fine until a later quantity change happens to recalculate — or until checkout shows a total that no longer matches the line items. That intermittent feel is exactly why conventions fail: the broken path is rare enough to pass casual clicking and common enough to ship.

Centralizing the rule does not remove the need for careful computeTotal logic. It does guarantee every writer path exercises the same function, so a wrong formula is consistent and therefore searchable. Scattered updates hide the same mistake behind “mostly works.”

A single disciplined write path is valuable by itself. What else does it buy when something breaks later?

Every Action Becomes A Replayable Snapshot

Two reducer properties stacked produce something stronger than a log file.

First, actions are serializable: plain JSON with no functions, class instances, or hidden refs — just { type: 'APPLY_COUPON', code: 'SAVE10' }. Second, cartReducer is pure: identical state plus identical action always yield identical next state, with no outside reads.

Together, replaying a sequence from the same start always lands on the same finish. That determinism is what Redux DevTools actually uses. It does not merely print that something happened; it stores the action list as data and recomputes the exact snapshot after any selected step when you click it.

Take the earlier bug — total wrong after applying a coupon then removing an item. With DevTools open, the session reads ADD_ITEM, ADD_ITEM, APPLY_COUPON, REMOVE_ITEM. After APPLY_COUPON the total looks right; after REMOVE_ITEM it does not. The bug has an address — the REMOVE_ITEM branch — without scattering console.log or replaying a user’s session by hand.

This advantage is not equal across libraries. Redux DevTools is the mature original. Zustand’s devtools middleware plugs set() into the same extension so updates appear as named actions. MobX usually mutates observables in place through proxies rather than a funnel of serializable actions, so its tooling emphasizes reaction graphs — which computeds re-ran and why — more than a full time-travel log.

Replay needs a deterministic function that always maps the same inputs to the same outputs. What else does that buy outside the browser extension?

A Reducer Is Just A Function You Can Call

cartReducer(startState, action) is an ordinary call: arguments in, entire next state out. Testing it needs no browser, click, or rendered component.

// store/cartReducer.test.js
import { cartReducer } from './cartReducer';

test('APPLY_COUPON recomputes total', () => {
  const startState = {
    items: [{ sku: 'A1', price: 20, quantity: 2 }],
    discount: 0,
    total: 40,
  };
  const nextState = cartReducer(startState, { type: 'APPLY_COUPON', code: 'SAVE10' });
  expect(nextState.discount).toBe(0.10);
  expect(nextState.total).toBe(36); // 40 minus 10 percent
});

The test finishes in milliseconds without a rendering library. It catches the earlier bug directly: if APPLY_COUPON skipped computeTotal, nextState.total would still be 40 and the assertion fails on the broken branch instead of a vague UI mismatch.

The same logic as a useState setter inside a click handler cannot be tested that way — and the reason is not JSX:

// hooks/useCoupon.js
import { useState } from 'react';

function useCoupon() {
  const [discount, setDiscount] = useState(0);
  const applyCoupon = code => setDiscount(getDiscountFor(code));
  return { discount, applyCoupon };
}
export default useCoupon;

Calling useCoupon() from a plain test throws. Hooks like useState only work during a React render (or inside another hook called during render), tracked against that component instance. That is the Rules of Hooks: unconditional calls in the same order every render, because React matches hook state by call position, not by name. Skip a hook on some renders and bookkeeping desyncs.

applyCoupon also fails to return useful state the way cartReducer does. setDiscount returns undefined. It schedules a re-render; the new value appears only on the next run of the component body. There is no return value to assert — the mechanism is “ask React to re-render,” not “compute and hand back a value.”

Testing it means rendering something and reading what appeared on screen:

// CartSummary.test.jsx
import { render, screen, fireEvent } from '@testing-library/react';
import CartSummary from './CartSummary';

test('applying coupon updates the displayed total', () => {
  render(<CartSummary />);
  fireEvent.click(screen.getByText('Apply SAVE10'));
  expect(screen.getByTestId('total')).toHaveTextContent('36');
});

Same fact under test — total after a coupon — but the assertion checks rendered text, after a full render and a simulated click.

A middle path is renderHook from React Testing Library: exercise a hook without JSX or clicks, then inspect result.current. It still needs React’s test renderer under act, which flushes updates and effects before assertions. Scaffolding remains because hook state still lives inside a mounted (even minimal) instance. cartReducer needed none of that; it was never tied to a component.

Testability is about coupling. What does the same idea look like when choosing where one store ends and another begins?

Where The Boundary Actually Goes

The opening invariants also answer a different question: where one store should stop and the next start.

Cart items, discount, and total belong together because they are coupled — changing one can invalidate facts that depend on the others. Theme does not belong in that reducer: nothing about theme determines cart.total, and nothing about the cart determines theme. Independent state that merely coexists in one app.

A real product usually grows several of these rule books, each unaware of the others:

cartStore     → items, discount, total
authStore     → user, session, permissions
themeStore    → theme
notifyStore   → toasts, unread count

In Redux that appears as slices — one reducer per portion of the tree — combined under a root without merging their logic. In Zustand it is separate create() calls (useCartStore, useAuthStore, …). In MobX it is separate observable classes with their own actions and invariants, without a shared reducer.

A component may read several stores in one render. A cart summary might read cartStore.total and authStore.user.currency to format money. Reads compose freely. Writes must not cross: the cart reducer should not reach into auth state, or the reverse.

If changing one store must also change another to keep a fact true — currency switching forcing a cart total recompute, for example — the boundary was drawn wrong. Either the pieces belong in one coordinated store, or they need an explicit bridge whose job is keeping them in sync, not an implicit dependency nobody documented.

Library choice still matters for team conventions, middleware, and ecosystem size, but those are secondary. The primary filter is structural: multiple writers plus shared invariants. Without that structure, React’s own Context, reducers, and props already cover most “I need global reads” stories. With that structure, a dedicated store — Redux Toolkit, Zustand, MobX, or a carefully shared useReducer — earns its keep by owning the rule book in one place.

Teams often discover this boundary late, after a cart, booking flow, or permissions matrix has already grown five mutation sites. Retrofitting a reducer is still cheaper than debugging intermittent totals. The earlier the invariant is named in code, the less the UI has to compensate with ad-hoc patches.

If a feature only ever has one writer and no cross-field rules, keep it local. If it has many writers and rules that must survive every writer, give those writes a single door.

The Actual Answer

Earlier coverage showed that reader count alone never justifies a library — React already answers those cases. This piece covers the other half: how many independent places can write, and whether those writes must preserve the same facts, is the real test.

A theme flag fails that test everywhere: one writer, no cross-field invariant, nothing to coordinate. A cart passes it everywhere at once: five writers, three facts any of them can break, a reducer that turns “five people must remember the rule” into “the rule runs unconditionally,” plus two useful side effects — debugging as a replayable timeline instead of guesswork, and tests that call a function instead of rendering a screen to scrape a number.

That is the line. Not component count. Writer count, and what must stay true across all of them.

Put another way: libraries for client state are tools for coordinating writers, not for distributing readers. Reader fan-out is React’s job. Writer coordination — and the invariants those writers must honor — is when Redux, Zustand, MobX, or an equivalent store becomes the honest answer rather than a habit.