Frontend Component and State Architecture Questions
Structuring client applications for maintainability: component architecture and code organization (composition and compound components, container/presentational split, controlled vs uncontrolled components, prop drilling and context, refs and imperative handles, folder and module boundaries as a codebase grows), state management and data flow (local vs global state, state colocation and lifting, reducers and stores, selectors and derived state, normalization, immutability, one-way flow, migrating a legacy global store incrementally), how the UI framework turns state into screen updates (hooks and effect dependencies, re-render behaviour, memoization trade-offs, keys, update batching, concurrent rendering and transitions, plus the same ideas in Angular services and change detection), data-fetching patterns (server-state caching, stale-while-revalidate, optimistic updates and rollback, request deduplication, cursor pagination, normalized GraphQL caches, merging live socket events and partial failures into local state, undo and redo history), URL state and route-scoped data, micro-frontends and cross-team state ownership and isolation, framework-agnostic web components, event-bus communication between distant widgets, and rendering strategy trade-offs (CSR, SSR, hydration and mismatches, progressive enhancement). Covers designing a frontend that scales in complexity without becoming brittle. Reusable component libraries as design systems, form and dialog implementation, accessibility practice, render-speed tuning, language typing mechanics, browser storage APIs and offline persistence, conflict-resolution protocols for collaborative editing, and testing strategy are covered elsewhere.
What is useReducer, and when would you choose it over useState? Use a multi-field form or an undo history as an example.
Sample Answer
Direct answer
useReducer(reducer, initialState) is a React hook for state where you describe what happened (an action such as { type: 'undo' }) and a separate pure function (one that returns the same output for the same inputs and changes nothing outside itself), the reducer, computes the next state from the current state and that action. It returns [state, dispatch]. react.dev puts it this way: "useReducer is very similar to useState, but it lets you move the state update logic from event handlers into a single function outside of your component." (https://react.dev/reference/react/useReducer). Choose it over useState when one user action changes several fields together, when the next value depends on rules about the previous state (undo history, form validation), or when you want to test the transitions without rendering anything. Keep useState for independent single values such as a toggle or a text box.
The two examples
Undo history. The state is { past: [], present, future: [] }. Setting a value pushes present onto past and clears future; undo pops past into present and pushes the old present onto future; redo does the reverse. Three fields change together on every action, which is the case a reducer expresses in one place.
Multi-field form. The state is { values, touched, errors, status }. A change action updates one value and recomputes which errors to show; blur marks a field as touched; submit validates everything and sets status. Without a reducer, each handler has to remember to update all four pieces in step.
// reducerChoice.test.jsx
import { describe, it, expect, afterEach } from 'vitest';
import { useReducer, useState, useRef } from 'react';
import { render, screen, act, cleanup, fireEvent } from '@testing-library/react';
afterEach(cleanup);
// --- undo history as a pure reducer ---------------------------------------
export const emptyHistory = (present) => ({ past: [], present, future: [] });
export function historyReducer(h, a) {
switch (a.type) {
case 'set':
return a.value === h.present ? h : { past: [...h.past, h.present], present: a.value, future: [] };
case 'undo':
return h.past.length === 0 ? h : { past: h.past.slice(0, -1), present: h.past.at(-1), future: [h.present, ...h.future] };
case 'redo':
return h.future.length === 0 ? h : { past: [...h.past, h.present], present: h.future[0], future: h.future.slice(1) };
default:
return h;
}
}
// --- multi-field form as a reducer ------------------------------------------
export const emptyForm = { values: { email: '', age: '' }, touched: {}, errors: {}, status: 'editing' };
const validate = (v) => ({
...(v.email.includes('@') ? {} : { email: 'Enter an email address' }),
...(Number(v.age) >= 18 ? {} : { age: 'Must be 18 or older' }),
});
const visible = (values, touched) =>
Object.fromEntries(Object.entries(validate(values)).filter(([field]) => touched[field]));
export function formReducer(f, a) {
switch (a.type) {
case 'change': {
const values = { ...f.values, [a.field]: a.value };
return { ...f, values, errors: visible(values, f.touched) };
}
case 'blur': {
const touched = { ...f.touched, [a.field]: true };
return { ...f, touched, errors: visible(f.values, touched) };
}
case 'submit': {
const errors = validate(f.values);
return Object.keys(errors).length
? { ...f, touched: { email: true, age: true }, errors, status: 'editing' }
: { ...f, errors: {}, status: 'submitted' };
}
default:
return f;
}
}
describe('undo history', () => {
it('reducer handles two undo actions dispatched in one event; a useState version reading its closure does not', () => {
const renderCounts = { reducer: 0, state: 0 };
const dispatches = new Set();
function WithReducer() {
renderCounts.reducer += 1;
const [h, dispatch] = useReducer(historyReducer, emptyHistory('a'));
dispatches.add(dispatch);
return (
<div>
<span data-testid="r">{h.present}</span>
{['b', 'c', 'd'].map((v) => <button key={v} onClick={() => dispatch({ type: 'set', value: v })}>r-set-{v}</button>)}
<button onClick={() => { dispatch({ type: 'undo' }); dispatch({ type: 'undo' }); }}>r-undo-twice</button>
<button onClick={() => dispatch({ type: 'redo' })}>r-redo</button>
</div>
);
}
function WithUseState() {
renderCounts.state += 1;
const [past, setPast] = useState([]);
const [present, setPresent] = useState('a');
const undo = () => { // reads past and present from the render's closure
if (past.length === 0) return;
setPresent(past[past.length - 1]);
setPast(past.slice(0, -1));
};
return (
<div>
<span data-testid="s">{present}</span>
{['b', 'c', 'd'].map((v) => <button key={v} onClick={() => { setPast([...past, present]); setPresent(v); }}>s-set-{v}</button>)}
<button onClick={() => { undo(); undo(); }}>s-undo-twice</button>
</div>
);
}
render(<><WithReducer /><WithUseState /></>);
for (const v of ['b', 'c', 'd']) {
fireEvent.click(screen.getByText('r-set-' + v));
fireEvent.click(screen.getByText('s-set-' + v));
}
expect([screen.getByTestId('r').textContent, screen.getByTestId('s').textContent]).toEqual(['d', 'd']);
fireEvent.click(screen.getByText('r-undo-twice'));
fireEvent.click(screen.getByText('s-undo-twice'));
console.log('after two undos in one click -> reducer:', screen.getByTestId('r').textContent, '| closure useState:', screen.getByTestId('s').textContent);
expect(screen.getByTestId('r').textContent).toBe('b');
expect(screen.getByTestId('s').textContent).toBe('c');
fireEvent.click(screen.getByText('r-redo'));
expect(screen.getByTestId('r').textContent).toBe('c');
expect(dispatches.size).toBe(1); // dispatch kept one identity across all those renders
});
it('transitions are testable without React', () => {
let h = emptyHistory('a');
h = historyReducer(h, { type: 'set', value: 'b' });
h = historyReducer(h, { type: 'undo' });
expect(h).toEqual({ past: [], present: 'a', future: ['b'] });
h = historyReducer(h, { type: 'set', value: 'z' }); // a new edit clears redo
expect(h.future).toEqual([]);
expect(historyReducer(h, { type: 'redo' })).toBe(h);
});
});
describe('multi-field form', () => {
it('one reducer keeps values, touched, errors and status consistent', () => {
let f = emptyForm;
f = formReducer(f, { type: 'change', field: 'email', value: 'nope' });
expect(f.errors).toEqual({}); // not touched yet: no error shown
f = formReducer(f, { type: 'blur', field: 'email' });
expect(f.errors).toEqual({ email: 'Enter an email address' });
f = formReducer(f, { type: 'change', field: 'email', value: 'a@b.co' });
expect(f.errors).toEqual({}); // email fixed; age not touched yet
f = formReducer(f, { type: 'submit' });
expect(f.status).toBe('editing');
expect(f.errors).toEqual({ age: 'Must be 18 or older' });
expect(f.touched).toEqual({ email: true, age: true }); // submit touches every field
f = formReducer(f, { type: 'change', field: 'age', value: '5' });
expect(f.errors).toEqual({ age: 'Must be 18 or older' }); // still shown: age is now touched
f = formReducer(f, { type: 'change', field: 'age', value: '30' });
f = formReducer(f, { type: 'submit' });
expect(f.status).toBe('submitted');
expect(f.errors).toEqual({});
});
});
What the run shows
How to read the test file: Vitest is the test runner. describe groups tests, it defines one test, and expect(a).toBe(b) asserts that two values are the same under Object.is, while toEqual compares objects field by field. Testing Library's render mounts a component into a simulated browser page (jsdom), screen.getByText finds an element by its text, fireEvent.click sends a click to it, and afterEach(cleanup) unmounts everything between tests. In the undo test, the reducer's dispatch is the function React returns from useReducer; collecting every dispatch it saw in a Set and checking that the set has one member proves the function never changed.
The undo test renders two components side by side. Both are given the same three edits (a to b, c, d). Then one click calls undo twice in the same event handler. The reducer version dispatches two actions, and React applies them one after the other to the latest state. The useState version reads past and present from the render's closure (the variables captured when the component function ran), so both calls compute from the same old values:
after two undos in one click -> reducer: b | closure useState: c
Tests 3 passed (3)
The reducer lands on b (two steps back from d), while the closure version lands on c (one step back).
Tracing the state shows why. Starting from a and applying set b, set c, set d gives { past: [a, b, c], present: d, future: [] }. The first undo moves the last past entry into present and pushes d onto future: { past: [a, b], present: c, future: [d] }. Because the reducer receives that result, the second undo continues from it: { past: [a], present: b, future: [c, d] }. A redo then gives { past: [a, b], present: c, future: [d] }, matching the test's final assertion of c. The closure version never sees the intermediate state: both calls read past = [a, b, c] and present = d from the render, so both compute present = c and past = [a, b].
The same step-by-step view applies to the form reducer (shown after each action):
| Action | values | touched | errors | status |
|---|---|---|---|---|
change email "nope" | email nope, age empty | none | none (untouched field) | editing |
blur email | same | email: "Enter an email address" | editing | |
change email "a@b.co" | email a@b.co | none | editing | |
submit | same | email, age | age: "Must be 18 or older" | editing |
change age "5" | age 5 | email, age | age: "Must be 18 or older" | editing |
change age "30" | age 30 | email, age | none | editing |
submit | same | email, age | none | submitted |
submit shows the age error even though the user never blurred that field, because it stores the result of validate directly as errors. It also marks every field as touched, which is what keeps errors visible on later edits: the test checks both touched after the failed submit and that typing a too-small age (5) right afterwards still reports Must be 18 or older. The test also asserts that dispatch kept a single identity across all renders; react.dev says "The dispatch function has a stable identity". The form test calls the reducer directly with no React: change an email while untouched (no error shown), blur it (error appears), fix it, submit with a bad age (status stays editing, both fields touched), type another bad age (the error stays), fix the age, submit (status submitted).
You could rescue the useState version by merging past and present into one state object and updating with a function form, setHistory(h => ...). That is a reducer written inline, which is the point: once fields move together, one transition function is the clearer home for the rules.
When to choose which
| Situation | Pick | Why |
|---|---|---|
| One independent value (modal open, input text) | useState | Less code, nothing to coordinate |
| Several fields that must change together | useReducer | One transition keeps them consistent |
| Next state depends on rules about the previous state | useReducer | Rules live in one named function |
| You want to unit-test transitions | useReducer | The reducer is a plain function, tested without React |
| Many components need the same updates | useReducer | Pass dispatch down instead of many setters |
These are guidelines drawn from the examples, not rules of the library.
Pitfalls
- A reducer must be pure: no fetches, no random ids, no mutation of the old state. react.dev says it "must be pure" and that in Strict Mode (a wrapper component,
<StrictMode>, that adds extra development-only checks such as calling functions that should be pure twice; it has no effect in production builds) "React will call your reducer and initializer twice in order to help you find accidental impurities." If the reducer mutated its input or generated a random id, the two calls would disagree and the bug would show up in development. - Return the same object when nothing changed (the
undowith an emptypastdoes), because React skips the re-render when the new state is identical underObject.is(JavaScript's same-value check: two objects are identical only if they are the same object in memory, so{ a: 1 }and another{ a: 1 }literal are different). - Do not use a reducer for state that is really derived. If a value can be calculated from other state during render, do not store it.
- More ceremony than
useStatefor trivial cases: three action types for one boolean is over-engineering.
Running the code
Files: reducerChoice.test.jsx, vitest.config.js.
npm init -y && npm pkg set type=module
npm i react@18.3.1 react-dom@18.3.1
npm i -D vitest@2.1.8 jsdom@25.0.1 @testing-library/react@16.1.0 @testing-library/dom@10.4.0
# vitest.config.js: export default { esbuild: { jsx: 'automatic' }, test: { environment: 'jsdom' } }
npx vitest run --reporter=verbose
Run in a node:22 container (Node 22.23.3) with exactly those versions.
Design the client-side state architecture for an e-commerce SPA with catalog browsing, a cart that responds instantly, live inventory updates and shared product entities. Which state lives where, and how does data flow for reads and writes?
Sample Answer
Direct answer
Split the state by who owns it and how long it lives, and give each kind exactly one home:
- Server-owned data (catalog lists, product details, live stock): fetched, cached, and stored normalized, meaning each product is stored once by id and every list or screen holds only ids. This is what makes "shared product entities" and live inventory updates cheap to keep correct.
- Client-owned shared state (the cart, as a list of pending operations on top of the server's last confirmed cart): lives in one store so the badge, the cart page and checkout agree.
- URL state (search text that was submitted, filters, sort, page, the open product): lives in the route so links, reload and the back button work.
- Local component state (a half-typed search box, an open dropdown, hover): stays in
useStateinside the component that uses it. - Derived state (cart count, line totals, "only 2 left"): computed from the above by selectors, never stored.
Reads flow one way: server or socket data goes through a reducer into the store, and components read slices through selectors. Writes flow as events: the UI dispatches an optimistic operation, the request goes out, and the server's answer confirms or rolls it back.
The vocabulary of that flow:
- Store. The one object that holds the shared state. It offers
get()(current state),subscribe(fn)(be told when state changes) anddispatch(action). - Action and dispatch. An action is a plain object describing what happened, such as
{ type: 'inventoryEvent', id: 'p2', stock: 4, version: 2 }. To dispatch is to hand it to the store. - Reducer. A pure function
(state, action) => next statethat returns a new state object instead of editing the old one. If it returns the same object, nothing changed and nobody is notified. - Selector. A function from the whole state to the one small value a component needs, such as
(s) => s.products[id].stock. A component re-renders only when what its selector returns changes. - Optimistic update. Showing the result of an action immediately, before the server has confirmed it, and undoing it if the server refuses.
- Request deduplication and background refetch. Several components asking for the same data trigger one request, and cached data is re-requested quietly while the old copy stays on screen.
A workable order for designing it: sort every piece of state by owner (the table in the next section), shape the server data as entities plus id lists, model the cart as confirmed plus pending operations, add versions to the socket messages, and finally decide what goes in the URL. The program below implements this core with a small hand-written store so every claim can be counted; in a production app the fetching layer would be a server-state library (cache, request deduplication, background refetch) feeding the same entity shape.
What lives where, and why
| State | Home | Reason |
|---|---|---|
| Product entities (title, price, stock, version) | normalized entity map keyed by id | One copy; list, detail and cart read the same record |
| Result lists ("all", "search: lamp") | arrays of ids | Reordering or filtering never duplicates entities |
| Cart | confirmed server cart plus a list of pending operations | Rollback is deleting an operation; no snapshots |
| Cart count, totals, displayed quantity | selectors over the two rows above | Derived values cannot drift out of sync |
| Search draft, dropdown open | useState in the component | No other part of the app needs it; changing it re-renders only that component |
| Filters, sort, page, selected product | URL | Shareable and survives reload |
Running implementation
The reducer holds entities, lists of ids and the cart. inventoryEvent is the WebSocket message (a WebSocket is a persistent two-way connection the server uses to push updates), and it carries a version so a late, older message cannot overwrite a newer one. The fake backend processes calls in order but lets the test deliver responses in any order, which is how real networks behave. The checks cover re-render counts (from the run), the optimistic cart, out-of-order replies, a server rejection (including one while another add is still pending), and a denormalized comparison at the end.
import { JSDOM } from 'jsdom';
import assert from 'node:assert/strict';
const dom = new JSDOM('<!doctype html><body></body>', { url: 'http://localhost' });
globalThis.window = dom.window; globalThis.document = dom.window.document;
globalThis.IS_REACT_ACT_ENVIRONMENT = true;
const React = (await import('react')).default;
const { render, act, cleanup, fireEvent } = await import('@testing-library/react');
const { useState, memo } = React;
const h = React.createElement;
// ================= 1. one store: normalized entities + lists of ids + cart as pending operations
const initial = { products: {}, lists: {}, cart: { confirmed: { version: 0, qty: {}, applied: [] }, ops: [], error: null } };
function reducer(s, a) {
switch (a.type) {
case 'listLoaded': { // entities by id; the list keeps ids only
const products = { ...s.products };
for (const p of a.products) products[p.id] = { ...products[p.id], ...p };
return { ...s, products, lists: { ...s.lists, [a.key]: a.products.map((p) => p.id) } };
}
case 'inventoryEvent': { // websocket: apply only if newer than what we hold
const cur = s.products[a.id];
if (!cur || a.version <= cur.version) return s;
return { ...s, products: { ...s.products, [a.id]: { ...cur, stock: a.stock, version: a.version } } };
}
case 'cartAddStarted': return { ...s, cart: { ...s.cart, error: null, ops: [...s.cart.ops, { opId: a.opId, id: a.id }] } };
case 'cartAddConfirmed': { // the server's cart replaces ours; ops it already includes are dropped
const c = s.cart;
if (a.cart.version <= c.confirmed.version) return { ...s, cart: { ...c, ops: c.ops.filter((o) => !a.cart.applied.includes(o.opId)) } };
return { ...s, cart: { ...c, confirmed: a.cart, ops: c.ops.filter((o) => !a.cart.applied.includes(o.opId)) } };
}
case 'cartAddFailed': return { ...s, cart: { ...s.cart, ops: s.cart.ops.filter((o) => o.opId !== a.opId), error: a.reason } };
default: return s;
}
}
function createStore() {
let state = initial; const subs = new Set();
return { get: () => state, subscribe: (f) => { subs.add(f); return () => subs.delete(f); },
dispatch: (a) => { const next = reducer(state, a); if (next !== state) { state = next; subs.forEach((f) => f()); } } };
}
// derived state is computed, never stored: displayed quantity = confirmed + pending operations
const qtyOf = (s, id) => (s.cart.confirmed.qty[id] || 0) + s.cart.ops.filter((o) => o.id === id).length;
const cartCount = (s) => Object.keys(s.products).reduce((n, id) => n + qtyOf(s, id), 0);
const cartTotal = (s) => Object.values(s.products).reduce((t, p) => t + p.priceCents * qtyOf(s, p.id), 0);
let store; const renders = {};
const hit = (k) => { renders[k] = (renders[k] || 0) + 1; };
const useSel = (sel) => React.useSyncExternalStore(store.subscribe, () => sel(store.get()));
// ================= 2. fake backend: processes calls in order, delivers responses when the test says so
function makeApi() {
const api = { stock: { p1: 3, p2: 5, p3: 0 }, cart: { version: 0, qty: {}, applied: [] }, held: [], calls: 0 };
api.addToCart = (opId, id) => { api.calls++;
if (api.stock[id] < 1) return Promise.reject(new Error('out of stock'));
api.cart = { version: api.cart.version + 1, qty: { ...api.cart.qty, [id]: (api.cart.qty[id] || 0) + 1 }, applied: [...api.cart.applied, opId] };
const snapshot = api.cart;
return new Promise((resolve) => api.held.push(() => resolve(snapshot))); };
api.deliver = async (index) => { await act(async () => { api.held.splice(index, 1)[0](); await Promise.resolve(); }); };
return api;
}
let opSeq = 0;
async function addToCart(api, id) { // optimistic path: dispatch first, then talk to the server
const opId = 'op' + ++opSeq;
store.dispatch({ type: 'cartAddStarted', opId, id });
try { store.dispatch({ type: 'cartAddConfirmed', cart: await api.addToCart(opId, id) }); }
catch (e) { store.dispatch({ type: 'cartAddFailed', opId, reason: e.message }); }
}
// ================= 3. components: each selects the smallest slice it needs
const ProductCard = memo(function ProductCard({ id }) {
const title = useSel((s) => s.products[id].title), stock = useSel((s) => s.products[id].stock);
hit('card ' + id);
return h('li', { 'data-card': id }, `${title} (${stock} left)`);
});
function Grid() { const ids = useSel((s) => s.lists.all.join(',')).split(','); hit('grid'); return h('ul', null, ids.map((id) => h(ProductCard, { key: id, id }))); }
function Detail({ id }) { const stock = useSel((s) => s.products[id].stock); hit('detail'); return h('p', { 'data-detail': true }, `detail ${id}: ${stock} left`); }
function CartBadge() { const n = useSel(cartCount), t = useSel(cartTotal), e = useSel((s) => s.cart.error); hit('badge');
return h('b', null, `cart ${n} / $${(t / 100).toFixed(2)}${e ? ' / ' + e : ''}`); }
function Search() { const [q, setQ] = useState(''); hit('search'); // local UI state: nothing outside this component needs it
return h('input', { 'aria-label': 'search', value: q, onChange: (e) => setQ(e.target.value) }); }
const App = () => h('div', null, h(Search), h(CartBadge), h(Grid), h(Detail, { id: 'p2' }));
// ================= 4. run it
store = createStore();
const api = makeApi();
store.dispatch({ type: 'listLoaded', key: 'all', products: [
{ id: 'p1', title: 'Mug', priceCents: 1200, stock: 3, version: 1 }, { id: 'p2', title: 'Lamp', priceCents: 4500, stock: 5, version: 1 },
{ id: 'p3', title: 'Desk', priceCents: 30000, stock: 0, version: 1 }] });
const { container } = render(h(App));
const text = (sel) => container.querySelector(sel).textContent;
const snap = () => ({ ...renders }); const diff = (a, b) => Object.fromEntries(Object.keys(b).map((k) => [k, b[k] - (a[k] || 0)]).filter(([, d]) => d));
let before = snap();
for (const v of ['l', 'la', 'lam']) fireEvent.change(container.querySelector('input'), { target: { value: v } });
console.log('typing 3 chars : re-renders', JSON.stringify(diff(before, renders)));
assert.deepEqual(diff(before, renders), { search: 3 });
before = snap();
await act(async () => store.dispatch({ type: 'inventoryEvent', id: 'p2', stock: 4, version: 2 }));
console.log('socket p2 stock 5->4 : re-renders', JSON.stringify(diff(before, renders)), '|', text('[data-card=p2]'), '|', text('[data-detail]'));
assert.deepEqual(diff(before, renders), { 'card p2': 1, detail: 1 });
assert.equal(text('[data-card=p2]'), 'Lamp (4 left)'); assert.equal(text('[data-detail]'), 'detail p2: 4 left');
before = snap();
await act(async () => store.dispatch({ type: 'inventoryEvent', id: 'p2', stock: 9, version: 1 })); // older event arriving late
console.log('late socket event v1 : re-renders', JSON.stringify(diff(before, renders)), '|', text('[data-detail]'));
assert.deepEqual(diff(before, renders), {}); assert.equal(text('[data-detail]'), 'detail p2: 4 left');
await act(async () => { addToCart(api, 'p1'); }); // optimistic: visible before any response
console.log('add p1, no response :', text('b'), '| calls to server', api.calls);
assert.equal(text('b'), 'cart 1 / $12.00');
await act(async () => { addToCart(api, 'p2'); }); // second add while the first is still in flight
assert.equal(text('b'), 'cart 2 / $57.00');
await api.deliver(1); // responses arrive out of order: the newer one first
console.log('p2 reply first :', text('b'));
assert.equal(text('b'), 'cart 2 / $57.00');
await api.deliver(0); // the older reply is ignored (version <= confirmed)
console.log('p1 reply (older) :', text('b'));
assert.equal(text('b'), 'cart 2 / $57.00'); assert.deepEqual(store.get().cart.confirmed.qty, { p1: 1, p2: 1 });
await act(async () => { await addToCart(api, 'p3'); }); // server says out of stock: the optimistic op is removed
console.log('add p3 (out of stock) :', text('b'));
assert.equal(text('b'), 'cart 2 / $57.00 / out of stock');
await act(async () => { addToCart(api, 'p1'); }); // a second p1 add is pending while a different add fails
await act(async () => { await addToCart(api, 'p3'); });
console.log('p1 pending, p3 fails :', text('b'));
assert.equal(text('b'), 'cart 3 / $69.00 / out of stock'); // only the failed operation was removed
await api.deliver(0);
assert.equal(text('b'), 'cart 3 / $69.00 / out of stock'); assert.deepEqual(store.get().cart.confirmed.qty, { p1: 2, p2: 1 });
console.log('all assertions passed');
// ================= 5. the same socket event against denormalized state (each list and view holds its own copy)
const denorm = { list: [{ id: 'p2', stock: 5 }], detail: { id: 'p2', stock: 5 } };
const patched = { ...denorm, list: denorm.list.map((p) => (p.id === 'p2' ? { ...p, stock: 4 } : p)) }; // the author remembered the list only
console.log('denormalized : list says', patched.list[0].stock, 'but detail says', patched.detail.stock);
assert.notEqual(patched.list[0].stock, patched.detail.stock);
cleanup();
Reading the program in the order it runs:
- Section 1, the store.
reducerhandles five actions.listLoadedcopies each product intoproductsby id (merging with any copy already there) and stores only the ids inlists.inventoryEventreplacesstockandversionon one product, but only when the message's version is higher than the stored one.createStorekeepsstatein a variable, anddispatchnotifies subscribers only when the reducer returned a different object. - The cart actions.
cartAddStartedappends{ opId, id }toops, the pending operations: the record of an add the user made that the server has not yet confirmed.cartAddConfirmedreceives the server's whole cart,a.cart, which has aversion(how many changes the server has applied),qtyper product andapplied, the ids of the operations it already counts. It does two things: it removes fromopsevery operation listed inapplied, because the server now counts those, and it replacesconfirmedonly when the reply's version is newer than the one held. An older reply still prunesopsbut is not allowed to replaceconfirmed.cartAddFailedremoves just the failed operation and records the reason. - Derived values.
qtyOfis the confirmed quantity plus the number of pending operations for that product;cartCountandcartTotalsum it over all products. Nothing about the cart total is stored. - Section 2, the fake backend.
api.addToCartrejects at once when the product has no stock. Otherwise it updates the server's cart immediately (version plus one, quantity plus one, the operation id appended toapplied) and takes asnapshotof it. It returns a promise that stays unresolved: its resolver is pushed ontoapi.heldin call order.api.deliver(index)removes one resolver fromheldand calls it, sodeliver(1)releases the second call's reply before the first one's. - Section 3, the components.
useSel(selector)wrapsuseSyncExternalStore, React's hook for reading a store that lives outside React: it subscribes to the store and re-renders the component only when the selected value differs from last time.memo(React.memo) skips re-rendering a component whose props did not change.hit(name)counts how many times a component function runs, which is what the printed re-render numbers come from. - Section 4, the run.
act(...)from Testing Library runs the code inside it and applies the resulting React updates before returning, so the next line sees the updated screen.snapanddifftake the render counters before and after each step and keep only the differences.
Run in a node:22 container with the packages under "Running the code", it prints:
typing 3 chars : re-renders {"search":3}
socket p2 stock 5->4 : re-renders {"card p2":1,"detail":1} | Lamp (4 left) | detail p2: 4 left
late socket event v1 : re-renders {} | detail p2: 4 left
add p1, no response : cart 1 / $12.00 | calls to server 1
p2 reply first : cart 2 / $57.00
p1 reply (older) : cart 2 / $57.00
add p3 (out of stock) : cart 2 / $57.00 / out of stock
p1 pending, p3 fails : cart 3 / $69.00 / out of stock
all assertions passed
denormalized : list says 4 but detail says 5
The cart step by step
The cart section of the store after each step of the run (confirmed is the last server cart held; ops are the pending operations):
| Step | confirmed.version | confirmed.qty | confirmed.applied | ops | Badge |
|---|---|---|---|---|---|
| Start | 0 | {} | [] | none | cart 0 / $0.00 |
| Add p1, no reply yet | 0 | {} | [] | op1:p1 | cart 1 / $12.00 |
| Add p2, no reply yet | 0 | {} | [] | op1:p1, op2:p2 | cart 2 / $57.00 |
| Reply to the p2 call (server cart version 2) | 2 | {p1:1, p2:1} | [op1, op2] | none | cart 2 / $57.00 |
Older reply (version 1, applied [op1]) | 2 | {p1:1, p2:1} | [op1, op2] | none | cart 2 / $57.00 |
| Add p3, server rejects | 2 | {p1:1, p2:1} | [op1, op2] | none | cart 2 / $57.00 / out of stock |
The server handled the p2 call after the p1 call, so its cart already contained both adds when it replied. That is why the reply to the second call is enough to clear both pending operations, and why the late version-1 reply must not replace it: it describes the cart as it was before the p2 add. The badge is $57.00 because 1200 + 4500 = 5700 cents.
Reading the run
- Local state is isolated. Typing three characters re-rendered only
Search(3 renders). Nothing else subscribed to a changing value, so the cards, the badge and the detail view did not run. - Live inventory touches only what shows the changed entity. A socket message for product
p2re-rendered exactly one card and the detail view (one render each); the other cards stayed idle because their selectors returned the same primitive values. The detail view and the grid agree because they read one record. - Late message ignored. A message with version 1 arriving after version 2 caused no render and no change. With the version check removed the detail view flips to
9 leftand the assertion fails (the failing output is printed after this list). - Optimistic cart.
cart 1 / $12.00is on screen while the server has not yet replied (the server had received 1 call). The displayed quantity isconfirmed + pending operations, so there is no separate "optimistic copy" to keep in sync. - Out-of-order replies. Two adds were in flight; the reply to the second arrived first and carried both items (version 2). The older reply (version 1) was then ignored because its version is not newer than the confirmed cart. Without that check the badge drops to
cart 1 / $12.00(printed after this list). - Rejection and rollback. The out-of-stock add (
p3) was removed from the pending operations and an error shown; the cart stayed atcart 2 / $57.00(1200 + 4500 cents). Rolling back the operation list cannot clobber other pending adds, which a "restore the previous state" snapshot could. The last step of the run shows it: a second p1 add is still waiting for its reply when the p3 add fails, and the badge readscart 3 / $69.00 / out of stock(1200 + 4500 + 1200 = 6900 cents), so only the failed operation was removed. A failure handler that cleared every pending operation would pass the earlier steps, because nothing else was pending then, and fail this one. - Why normalization matters. The last line applies the same stock change to denormalized state, where the list and the detail view each hold a copy of the product. The author patched the list and forgot the detail copy: the list says 4 and the detail says 5. Every extra place a product is copied is one more place to forget.
What happens when each version check is removed
The two sed commands in the run instructions replace one condition with false. Run in the same container, the cart check removed (a.cart.version <= c.confirmed.version becomes false) prints, trimmed to the lines that matter:
p2 reply first : cart 2 / $57.00
p1 reply (older) : cart 1 / $12.00
AssertionError [ERR_ASSERTION]: Expected values to be strictly equal:
+ actual - expected
+ 'cart 1 / $12.00'
- 'cart 2 / $57.00'
and the socket check removed (a.version <= cur.version becomes false) prints:
late socket event v1 : re-renders {"card p2":1,"detail":1} | detail p2: 9 left
AssertionError [ERR_ASSERTION]: Expected values to be strictly deep-equal:
+ actual - expected
+ {
+ 'card p2': 1,
+ detail: 1
+ }
- {}
In the first case the stale reply overwrote the version-2 cart with a cart that only knew about p1. In the second, the late version-1 message put stock: 9 back over the newer stock: 4.
Read and write paths
- Read (catalog): route params and URL filters decide the query, the response is written into the entity map plus a list of ids, components select what they need. Navigating from a list to a detail view shows the entity already in the store immediately and refetches the full record in the background.
- Read (inventory): the socket pushes
{ id, stock, version }. The reducer merges it into the entity if newer. A reconnect should refetch the visible products, because messages sent while disconnected are lost. - Write (cart): dispatch
cartAddStarted(optimistic), send the request with an operation id, then dispatch confirmed (the server's cart replaces the confirmed one) or failed (remove the operation, show the reason). - Checkout: never trust the optimistic cart or the displayed stock. The server re-validates stock and price at checkout and the UI renders its answer.
Trade-offs and pitfalls
- Normalization is not free. Writes must merge into the map, lists must be rebuilt from ids, and deleting an entity needs cleanup of every list that references it. If a record appears in only one place, keep it nested; normalize when the same record shows up in lists, detail views and the cart.
- Selectors must return stable values. Selecting a new array or object each call makes a component re-render on every store change; the run's counts come from selecting primitives (
title,stock,cartCount). - Do not put everything in the global store. The search draft lived in
useState, and the re-render count shows the benefit. Move state up only when a second component truly needs it. - Optimism has a limit. Add-to-cart is cheap to undo; payment is not and should show a pending state.
- This is one reducer, one fake backend and a handful of hand-ordered events. It demonstrates the data flow and its counts, not scale or latency.
Running the code
npm i react@18.3.1 react-dom@18.3.1 @testing-library/react@16.0.1 @testing-library/dom@10.4.0 jsdom@25.0.1
node shop-state.mjs # prints the run above and "all assertions passed"
sed 's/a.cart.version <= c.confirmed.version/false/' shop-state.mjs > no-version-check.mjs && node no-version-check.mjs # fails: cart 1 / $12.00
sed 's/a.version <= cur.version/false/' shop-state.mjs > no-event-version.mjs && node no-event-version.mjs # fails: late event applied
sed 's/ops: s.cart.ops.filter((o) => o.opId !== a.opId)/ops: []/' shop-state.mjs > clear-all-ops.mjs && node clear-all-ops.mjs # fails: pending p1 add lost on a rejection
Save the listing as shop-state.mjs beside a package.json containing { "type": "module" }. Tested on node:22 (v22.23).
A memoized child stopped updating after a refactor: the parent now edits a nested object in place and passes the same reference down. How do you find the bug, why does it happen, and where do you fix it?
Sample Answer
The memoized child is stale because the parent changed an object in place and handed the same reference down; memo (React's wrapper that remembers a component's last output and skips re-rendering it when its props are unchanged) compares props with Object.is (JavaScript's same-value check: two objects are equal only if they are the very same object in memory), sees the identical object, and skips the render. Find it by confirming the child is skipped and that its prop's content changed under an unchanged reference, locate the writer of that object, and fix it there by producing new objects along the changed path. Comparing deeply in the child does not fix it, and removing memo only hides it: the parent still re-renders, so the child shows the new value while the object is still being mutated.
react.dev says of memo: "React compares every prop in your component with its previous value using the Object.is comparison."
Ordered checks
- Reproduce and observe renders. Count renders of the child while triggering the edit, either with a counter in the component (as the demo does) or with the React DevTools Profiler (a tab in the React DevTools browser extension that records which components React rendered). To use it: open the Profiler tab, click the record button, click the app's edit button, click stop. A commit is one batch of changes React applies to the page, and the Profiler lists one bar per commit near the top. Select the commit caused by your click and read the flame chart below it: each bar is a component, coloured bars rendered in that commit, and grey bars did not render. Expected here: the parent is coloured, the memoized child is grey, and the screen shows the old value. This tells you the data changed but the child was never asked to re-render.
- Compare identity with content. Log
prevSettings === settingsnext to a serialized copy of the content. Same reference but different content means mutation. TheuseMutationAlarmhook in the demo automates this in development, and it fired in the failing case. Line by line:lastis auseRef, a box that keeps its contents between renders without causing a re-render. TheuseEffecthas no dependency list, so it runs after every render. Inside it,snapis the current content as a JSON string. If a previous record exists, andlast.current.ref === value(the same object as the previous render) whilelast.current.snap !== snap(different content), nothing replaced the object but its content changed, so it pushes an alarm. Finally it stores the current reference and snapshot for the next comparison. Trace for the in-place edit: after mount it stores the settings object with snapshot{"theme":{"color":"blue"},"lang":"en"}; the click changessettings.theme.colortoredon that same object and forces a re-render; the effect finds the same reference with a snapshot that now saysred, and the alarm fires. With the immutable edit the reference is new, so theref ===test is false and nothing fires. - Find the writer. Search for the object's name where it is assigned to,
pushed to orsorted (handlers, reducers, a helper that "normalizes" settings). The first place that writes without creating a new object is the bug. - Fix at the writer.
{ ...settings, theme: { ...settings.theme, color } }creates new objects along the path, so the child sees a different reference. - Lock it in. Keep the regression test and consider freezing state in development.
Why not fix it in the child
- Dropping
memomakes the stale child disappear, because the parent re-renders and the child re-renders with it, but the object is still mutated in place (the alarm still fires, as the fourth test shows) and every consumer re-renders on every parent render. - A deep-equality comparator receives
prevandnextprops that are the same mutated object, so it also reports "equal". The third test runs this and the child still showsblue. - The mutation will also break effects, undo and anything else comparing references.
// memo-mutation-bug.test.jsx
import { useState, useRef, useEffect, memo } from 'react';
import { render, screen, fireEvent } from '@testing-library/react';
const alarms = [];
// Dev-only probe: same reference as last commit but different content means someone mutated it.
function useMutationAlarm(name, value) {
const last = useRef(null);
useEffect(() => {
const snap = JSON.stringify(value);
if (last.current && last.current.ref === value && last.current.snap !== snap) alarms.push(name + ' mutated in place');
last.current = { ref: value, snap };
});
}
let childRenders = 0;
const Swatch = memo(function Swatch({ settings }) {
childRenders++;
return <b data-testid="color">{settings.theme.color}</b>;
});
function makeParent(edit, Child = Swatch) {
return function Parent() {
const [settings, setSettings] = useState({ theme: { color: 'blue' }, lang: 'en' });
const [, force] = useState(0);
useMutationAlarm('settings', settings);
return <><Child settings={settings} />
<button onClick={() => edit(settings, setSettings, force)}>edit</button></>;
};
}
const mutateEdit = (settings, _set, force) => { settings.theme.color = 'red'; force(n => n + 1); };
const immutableEdit = (settings, set) => set({ ...settings, theme: { ...settings.theme, color: 'red' } });
function scenario(Parent) {
alarms.length = 0; childRenders = 0;
render(<Parent />);
fireEvent.click(screen.getByText('edit'));
const seen = { shows: screen.getByTestId('color').textContent, renders: childRenders, alarms: [...alarms] };
console.log('child renders:', seen.renders, '| shows:', seen.shows, '| alarms:', JSON.stringify(seen.alarms));
return seen;
}
test('fix in the parent: replace the objects on the path to the change', () => {
expect(scenario(makeParent(immutableEdit))).toEqual({ shows: 'red', renders: 2, alarms: [] });
});
test('control: the in-place edit leaves the memoized child stale and trips the alarm', () => {
expect(scenario(makeParent(mutateEdit))).toEqual({ shows: 'blue', renders: 1, alarms: ['settings mutated in place'] });
});
test('a deep-equality comparator on the child does not rescue the mutation', () => {
const deepEqual = (a, b) => JSON.stringify(a) === JSON.stringify(b);
const Deep = memo(({ settings }) => { childRenders++; return <b data-testid="color">{settings.theme.color}</b>; },
(p, n) => deepEqual(p.settings, n.settings));
expect(scenario(makeParent(mutateEdit, Deep))).toEqual({ shows: 'blue', renders: 1, alarms: ['settings mutated in place'] });
});
test('removing memo hides the symptom but the mutation is still there', () => {
const Plain = ({ settings }) => { childRenders++; return <b data-testid="color">{settings.theme.color}</b>; };
expect(scenario(makeParent(mutateEdit, Plain))).toEqual({ shows: 'red', renders: 2, alarms: ['settings mutated in place'] });
});
Printed by the run, in test order (fix, control, deep comparator, no memo):
child renders: 2 | shows: red | alarms: []
child renders: 1 | shows: blue | alarms: ["settings mutated in place"]
child renders: 1 | shows: blue | alarms: ["settings mutated in place"]
child renders: 2 | shows: red | alarms: ["settings mutated in place"]
Pitfalls
- The alarm costs a serialization per render, so keep it out of production builds.
- The mutating handler forces a parent re-render (
force), which is the common way the bug hides: the parent appears to update while the child does not.
Running the code
Tested with Node 22, React 18.3.1, jsdom 25.0.1 and Vitest 2.1.4.
mkdir demo && cd demo && npm init -y
npm i react@18.3.1 react-dom@18.3.1 @testing-library/react@16.0.1 @testing-library/dom@10.4.0 jsdom@25.0.1 vitest@2.1.4
# vitest.config.mjs
echo "export default { esbuild: { jsx: 'automatic' }, test: { environment: 'jsdom', globals: true } };" > vitest.config.mjs
# save memo-mutation-bug.test.jsx next to it, then (Node 22):
npx vitest run memo-mutation-bug.test.jsx
A complex screen has many independent widgets that must react to each other: notifications, a global filter and a shared selection. Compare the ways they could communicate (events, a shared bus, lifted state or context, message passing), and say which you would choose and why, considering testability, leaks and performance.
Sample Answer
Direct answer
The three things in the question are different kinds of data, so they should not share one mechanism. The global filter and the shared selection are state: they always have a current value that a newly mounted widget must be able to read. Keep them in the nearest common owner (lifted state: the state lives in a parent component that sits above every widget that needs it) and expose them either through one context per concern (React context: a way to hand a value to any descendant without passing props down each level) or through a small store (a plain object that holds the state outside React and tells subscribers when it changes) that widgets read with selectors (a selector is a function that picks the one slice, meaning the one part of the state, that a widget needs, such as (s) => s.selection, so the widget re-renders only when that part changes). Notifications are events: something happened once, and only listeners that exist right now care. Use an event bus (or a toast service with show()) for them. Message passing between windows, tabs or workers (BroadcastChannel and postMessage send a copy of a value to another window, tab or worker; a worker is a background script that runs on its own thread) is for data that must leave the current JavaScript context, not for widgets on one screen. I would choose lifted state with separate contexts or a sliced store for the filter and the selection, and an injected event bus for notifications.
The four ways to communicate
| Option | Delivery | Late subscriber sees the current value? | Re-render cost | Leak risk | Testability |
|---|---|---|---|---|---|
| Lifted state with one context object | React re-render of consumers | Yes | Every consumer re-renders when any field changes (measured below: the row 1, 1, 1 is one render each for Toast, Filter and Selection) | none | Render the provider with chosen values |
| Lifted state with one context per concern, or a store read through slices | React re-render of consumers of that slice | Yes | Only consumers of the changed slice (measured below) | none for contexts; a store needs unsubscribe on unmount | Same, or set the store directly |
Event bus (including DOM custom events on document) | Synchronous calls to handlers | No: an event reaches only handlers that are already subscribed | Only the handlers that subscribed | A handler outlives its widget unless removed (measured below with DOM custom events, which work the same way) | Create a fresh bus per test and assert its listener count |
Message passing (BroadcastChannel, postMessage, workers) | Asynchronous, payload is a copy | No | Receiver decides | Close the channel | Needs async waiting; payloads must be cloneable (copyable by the structured clone algorithm, so no functions) |
DOM-level delegation is a fifth technique for a different job. One listener on a container receives bubbling events (events that travel up from the element where they happened to each of its ancestors) from all of its children, including children added later, so it suits many identical items (rows, buttons) inside one container; it does not connect widgets that live in different parts of the page.
Evidence from a run
The first part renders three widgets (Toast, Filter, Selection), changes only the selection, and counts how many of them re-render. The second shows what message passing requires. The third shows DOM custom events, delegation and the leak.
// setup.mjs
import { JSDOM } from 'jsdom';
const dom = new JSDOM('<!doctype html><body></body>', { url: 'http://localhost/' });
globalThis.window = dom.window;
globalThis.document = dom.window.document;
globalThis.IS_REACT_ACT_ENVIRONMENT = true;
// widget-comms.mjs
import './setup.mjs';
import assert from 'node:assert/strict';
import React, { createContext, useContext, useState, useSyncExternalStore } from 'react';
import { render, act, cleanup } from '@testing-library/react';
const h = React.createElement;
// ---------- Part 1: how many widgets re-render when only the selection changes?
let renders;
const count = (name) => { renders[name] = (renders[name] ?? 0) + 1; };
const reset = () => { renders = {}; };
// A tiny external store with selectors, read through useSyncExternalStore.
function createStore(initial) {
let state = initial; const listeners = new Set();
return {
get: () => state,
set(patch) { state = { ...state, ...patch }; listeners.forEach((l) => l()); },
subscribe(l) { listeners.add(l); return () => listeners.delete(l); },
};
}
const useSlice = (store, pick) => useSyncExternalStore(store.subscribe, () => pick(store.get()));
// A) one context object holding everything
function singleContextApp() {
const Ctx = createContext(); let api;
function Provider({ children }) {
const [state, setState] = useState({ filter: 'all', selection: null, toast: null });
api = { select: (s) => setState((x) => ({ ...x, selection: s })) };
return h(Ctx.Provider, { value: state }, children);
}
const Toast = () => { count('Toast'); return h('i', null, String(useContext(Ctx).toast)); };
const Filter = () => { count('Filter'); return h('i', null, useContext(Ctx).filter); };
const Selection = () => { count('Selection'); return h('i', null, String(useContext(Ctx).selection)); };
return { tree: h(Provider, null, h(Toast), h(Filter), h(Selection)), select: (s) => api.select(s) };
}
// B) separate contexts, one per concern
function splitContextApp() {
const F = createContext(), S = createContext(), T = createContext(); let api;
function Provider({ children }) {
const [filter] = useState('all'); const [selection, setSelection] = useState(null); const [toast] = useState(null);
api = { select: setSelection };
return h(F.Provider, { value: filter }, h(S.Provider, { value: selection }, h(T.Provider, { value: toast }, children)));
}
const Toast = () => { count('Toast'); return h('i', null, String(useContext(T))); };
const Filter = () => { count('Filter'); return h('i', null, useContext(F)); };
const Selection = () => { count('Selection'); return h('i', null, String(useContext(S))); };
return { tree: h(Provider, null, h(Toast), h(Filter), h(Selection)), select: (s) => api.select(s) };
}
// C) one store, each widget subscribes to its own slice
function storeApp() {
const store = createStore({ filter: 'all', selection: null, toast: null });
const Toast = () => { count('Toast'); return h('i', null, String(useSlice(store, (s) => s.toast))); };
const Filter = () => { count('Filter'); return h('i', null, useSlice(store, (s) => s.filter)); };
const Selection = () => { count('Selection'); return h('i', null, String(useSlice(store, (s) => s.selection))); };
return { tree: h(React.Fragment, null, h(Toast), h(Filter), h(Selection)), select: (s) => store.set({ selection: s }) };
}
function measure(make) {
reset(); const app = make();
render(app.tree);
reset(); // ignore the first render, count only the update
act(() => app.select('row-7'));
const out = { Toast: renders.Toast ?? 0, Filter: renders.Filter ?? 0, Selection: renders.Selection ?? 0 };
cleanup();
return out;
}
const results = { 'single context': measure(singleContextApp), 'one context per concern': measure(splitContextApp), 'store with slices': measure(storeApp) };
console.log('renders after one selection change (Toast, Filter, Selection):');
for (const [k, v] of Object.entries(results)) console.log(` ${k.padEnd(24)} ${v.Toast}, ${v.Filter}, ${v.Selection}`);
assert.deepEqual(results['store with slices'], { Toast: 0, Filter: 0, Selection: 1 });
assert.deepEqual(results['one context per concern'], { Toast: 0, Filter: 0, Selection: 1 });
assert.throws(() => assert.deepEqual(results['single context'], { Toast: 0, Filter: 0, Selection: 1 }), (e) => e.actual.Toast === 1);
// ---------- Part 2: message passing (BroadcastChannel) between windows, tabs or workers. This part uses Node 22's BroadcastChannel.
const a = new BroadcastChannel('widgets'), b = new BroadcastChannel('widgets');
const sent = { id: 7, tags: ['x'] };
let delivered = false;
const got = new Promise((resolve) => { b.onmessage = (e) => { delivered = true; resolve(e.data); }; });
a.postMessage(sent);
assert.equal(delivered, false, 'delivery is asynchronous: nothing has arrived when postMessage returns');
const received = await got;
assert.deepEqual(received, sent); assert.notEqual(received, sent); // equal contents, but a copy
assert.throws(() => a.postMessage({ fn() {} }), (e) => e.name === 'DataCloneError');
console.log('BroadcastChannel: asynchronous, payload is a copy:', received !== sent, '| function payload rejected with', 'DataCloneError');
a.close(); b.close();
// ---------- Part 3: DOM custom events: synchronous, global, and they outlive the widget unless removed
function domScenario(useSignal) {
const calls = [];
const widget = () => {
const controller = new window.AbortController(); // the page's own AbortController, which jsdom's addEventListener accepts
document.addEventListener('filter:change', (e) => calls.push(e.detail), useSignal ? { signal: controller.signal } : undefined);
return () => controller.abort(); // the widget's teardown
};
const teardown = widget();
document.dispatchEvent(new window.CustomEvent('filter:change', { detail: 'open' }));
const synchronous = calls.length === 1; // delivered before dispatchEvent returned
teardown();
document.dispatchEvent(new window.CustomEvent('filter:change', { detail: 'closed' }));
return { synchronous, callsAfterTeardown: calls.length - 1 };
}
const withSignal = domScenario(true), withoutSignal = domScenario(false);
console.log('DOM custom event, listener added with a signal :', JSON.stringify(withSignal));
console.log('DOM custom event, listener added without a signal:', JSON.stringify(withoutSignal));
assert.deepEqual(withSignal, { synchronous: true, callsAfterTeardown: 0 });
assert.equal(withoutSignal.callsAfterTeardown, 1, 'without cleanup the dead widget still receives events');
// Delegation: one listener on a container hears bubbling events from children that are added later.
const container = document.createElement('div'); document.body.append(container);
const heard = [];
container.addEventListener('widget:select', (e) => heard.push(e.target.dataset.id));
for (const id of ['a', 'b']) { // children created after the listener was attached
const child = document.createElement('button'); child.dataset.id = id; container.append(child);
child.dispatchEvent(new window.CustomEvent('widget:select', { bubbles: true }));
}
const unrelated = document.createElement('button'); document.body.append(unrelated);
unrelated.dispatchEvent(new window.CustomEvent('widget:select', { bubbles: true })); // outside the container: not heard
console.log('delegation: one listener heard', JSON.stringify(heard), 'from children added later, and nothing from outside the container');
assert.deepEqual(heard, ['a', 'b']);
console.log('ALL OK');
Run in a node:22 container (Node 22), it prints:
renders after one selection change (Toast, Filter, Selection):
single context 1, 1, 1
one context per concern 0, 0, 1
store with slices 0, 0, 1
BroadcastChannel: asynchronous, payload is a copy: true | function payload rejected with DataCloneError
DOM custom event, listener added with a signal : {"synchronous":true,"callsAfterTeardown":0}
DOM custom event, listener added without a signal: {"synchronous":true,"callsAfterTeardown":1}
delegation: one listener heard ["a","b"] from children added later, and nothing from outside the container
ALL OK
What this shows:
- A single context object that holds every field re-renders all three widgets when only the selection changes (1, 1, 1). The test asserts that the Toast and Filter counts are 0 for the alternatives and fails for the single object.
- One context per concern and a store with slices both re-render only the Selection widget (0, 0, 1).
BroadcastChanneldelivered asynchronously and handed over a copy, not the same object; a function in the payload was rejected withDataCloneError.- A DOM custom event is delivered synchronously, and a listener added without cleanup still fired after its widget was torn down (
callsAfterTeardown: 1); with anAbortSignalit did not. A listener on a container heard bubbling events from children added later, and none from outside the container.
Reading the code
- Part 1, the store.
createStorekeeps one object,state, and aSetof listener functions.set(patch)builds a new state object and calls every listener;subscribe(l)adds a listener and returns the function that removes it.useSlice(store, pick)hands those touseSyncExternalStore(subscribe, getSnapshot): after each notification React callsgetSnapshot, which here ispick(store.get()), and re-renders the component only if the result is different from last time (compared withObject.is, which treats equal strings as the same).pickis a selector, for example(s) => s.selection, so the Toast widget, whose selector returnss.toast, sees the samenullbefore and after a selection change and does not re-render. - Part 1, the counts.
count('Toast')runs every time the Toast function runs.measurerenders once, resets the counters so the first render is ignored, changes only the selection insideact(Testing Library's wrapper that lets React finish its updates before the next line), and reads the counters. The output row1, 1, 1therefore means Toast, Filter and Selection each rendered once for that single change. - Part 2. Two
BroadcastChannelobjects with the same name stand in for two windows, tabs or workers. The firstassert.equal(delivered, false)runs right afterpostMessagereturns and proves nothing has arrived yet.received !== sentproves the receiver got a copy. The function payload throwsDataCloneErrorbecause the structured clone algorithm cannot copy functions. - Part 3.
domScenarioadds a listener for a custom event, dispatches it once, tears the widget down, and dispatches again. Thesynchronousflag is true when the handler had already run by the timedispatchEventreturned. The delegation check attaches one listener tocontainerbefore any child exists, then adds two buttons and dispatches a bubbling event from each.
Event flow and cleanup (pseudocode)
Toast widget mount: controller = new AbortController()
bus.on("toast:show", showToast, { signal: controller.signal })
Any feature: bus.emit("toast:show", { text: "Saved" }) // fire and forget
Toast widget unmount: controller.abort() // every subscription of this widget ends
Filter / Selection: owner holds { filter, selection } in state
widgets read their own slice; writers call setFilter / setSelection
Choosing, and what would change it
- Testability. Props and providers need no global setup. A module-level bus is shared by every test in a file, so inject the bus (pass it down or provide it through context) and create a fresh one per test.
- Leaks. Contexts and stores have nothing to forget; buses and DOM listeners do. Tie every subscription to an
AbortSignalor an effect cleanup, and assertlistenerCount() === 0after unmount. - Performance. The counts above come from three widgets; the effect grows with the number of consumers of a single context object. Splitting by concern is cheap and is the first thing to try before adding a library.
- What flips the choice. If the toast must also be visible to a widget that mounts later, it is state (a list of toasts) and belongs in the store, with the bus used only to trigger additions. If widgets are separate micro-frontends that do not share a React tree, custom DOM events or a bus on a shared object replace context.
Running the code
npm init -y && npm pkg set type=module
npm i react@18.3.1 react-dom@18.3.1 @testing-library/react@16.0.1 @testing-library/dom@10.4.0 jsdom@25.0.1
node widget-comms.mjs # Node 22; setup.mjs in the same folder
Two setState calls in a click handler produce one render, but the same two calls inside a setTimeout used to produce two. Explain batching and what changed in React 18.
Sample Answer
Batching means React groups several state updates into one re-render instead of re-rendering after each setter call (a setter is the second value useState returns, such as setCount). Before React 18 this happened only for updates made inside React's own event handlers (an onClick prop, for example). Updates made from a setTimeout, a promise callback or a native DOM listener ran synchronously, so each setter call caused its own render. React 18 batches updates from every source, which is called automatic batching, but only for apps that mount with createRoot. An app still mounted with the legacy ReactDOM.render keeps the old behaviour. Mounting is the one line in the app's entry file that attaches React to a page element: createRoot(element).render(<App />) creates a modern root (the object React 18 uses to manage a mounted tree), while ReactDOM.render(<App />, element) creates a legacy root that works as React 17 did.
What the docs say changed
The React 18 release post says: "Batching is when React groups multiple state updates into a single re-render for better performance. Without automatic batching, we only batched updates inside React event handlers. Updates inside of promises, setTimeout, native event handlers, or any other event were not batched in React by default."
The useState reference describes the mechanism from the handler's point of view: "It updates the screen after all the event handlers have run and have called their set functions." The setter does not render; it queues an update, and React renders once afterwards with all queued updates applied.
What the setter does, step by step
setCount(c => c + 1)records the update in a queue attached to that state variable. The variable in your current closure (thecountyou read in this render's function) does not change.setFlag(f => !f)queues a second update on another variable.- When the current batch ends, React runs the component function once. It applies both queued updates in order, so the function sees the new
countand the newflagtogether. - React compares the new output to the old and updates the DOM.
The visible consequences are that the screen never shows a half-updated state (count changed but flag not yet), and that the component function runs once instead of twice.
Measured: the same two setters from several triggers on two root types
The component calls two setters (count and flag) per trigger and counts how many times its function runs. The demo mounts it once with createRoot and once with the legacy ReactDOM.render, fires each trigger, and asserts the counts. On the modern root it fires four triggers (a React onClick, a setTimeout, a promise callback and a native DOM listener) plus a flushSync case; on the legacy root it fires three (onClick, setTimeout, promise). flushSync is the React API that forces a synchronous render, and here it sits between the two setters.
The first lines of the listing are environment setup. JSDOM builds a fake browser page and the next three lines expose its window, document and navigator as globals. IS_REACT_ACT_ENVIRONMENT is set to true while React and Testing Library load, then to false before the measurements: with false, React treats updates as arriving from a real page, outside the act() test wrapper that would otherwise flush them, so the batching you see is the real batching. The legacy mount function swaps console.error for a collector because ReactDOM.render logs a deprecation warning through it in React 18, and the demo prints the start of that message. The hooks and Testing Library names that the listing destructures are not used below and can be ignored.
// batching.mjs
import { JSDOM } from 'jsdom';
import assert from 'node:assert/strict';
const dom = new JSDOM('<!doctype html><body></body>', { url: 'http://localhost/' });
globalThis.window = dom.window;
globalThis.document = dom.window.document;
Object.defineProperty(globalThis, 'navigator', { value: dom.window.navigator });
globalThis.IS_REACT_ACT_ENVIRONMENT = true;
const React = (await import('react')).default;
const { useState, useEffect, useMemo, useCallback, useRef, memo, useReducer } = React;
const { render, screen, fireEvent, cleanup, act, waitFor } = await import('@testing-library/react');
const h = React.createElement;
const ReactDOM = await import('react-dom');
const { createRoot } = await import('react-dom/client');
const { flushSync } = ReactDOM;
globalThis.IS_REACT_ACT_ENVIRONMENT = false; // updates happen outside act(), as in a real page
let renders = 0;
let api;
function Counter() {
const [count, setCount] = useState(0);
const [flag, setFlag] = useState(false);
renders++;
api = {
twice() { setCount((c) => c + 1); setFlag((f) => !f); },
twiceFlushed() { flushSync(() => setCount((c) => c + 1)); flushSync(() => setFlag((f) => !f)); },
staleTwice() { setCount(count + 1); setCount(count + 1); }, // both read this render's count
updaterTwice() { setCount((c) => c + 1); setCount((c) => c + 1); },
};
return h('button', { id: 'b', 'data-count': count, onClick: () => api.twice() }, count + ':' + flag);
}
const tick = () => new Promise((r) => setTimeout(r, 30));
const results = {};
async function measure(name, mount, trigger) {
renders = 0;
const host = document.createElement('div');
document.body.appendChild(host);
const unmount = mount(host);
await tick();
const before = renders;
await trigger(host.firstChild);
await tick();
results[name] = renders - before;
console.log(name.padEnd(36), '2 setState calls ->', results[name], 'render(s), label', host.firstChild.textContent);
unmount();
host.remove();
}
const modern = (host) => { const root = createRoot(host); root.render(h(Counter)); return () => root.unmount(); };
const legacyWarnings = [];
const legacy = (host) => {
const orig = console.error;
console.error = (...a) => legacyWarnings.push(String(a[0]).slice(0, 60));
ReactDOM.render(h(Counter), host);
console.error = orig;
return () => { const o = console.error; console.error = () => {}; ReactDOM.unmountComponentAtNode(host); console.error = o; };
};
const triggers = {
'React onClick': (btn) => btn.click(),
'setTimeout': () => new Promise((r) => setTimeout(() => { api.twice(); r(); }, 0)),
'promise .then': () => Promise.resolve().then(() => api.twice()),
'native listener': (btn) => {
btn.addEventListener('ping', () => api.twice()); // a listener added outside React
btn.dispatchEvent(new window.Event('ping'));
},
};
for (const [n, t] of Object.entries(triggers)) await measure('createRoot + ' + n, modern, t);
await measure('createRoot + flushSync between', modern, () => api.twiceFlushed());
await measure('legacy ReactDOM.render + onClick', legacy, triggers['React onClick']);
await measure('legacy ReactDOM.render + setTimeout', legacy, triggers['setTimeout']);
await measure('legacy ReactDOM.render + promise', legacy, triggers['promise .then']);
console.log('legacy warning starts:', legacyWarnings[0]);
assert.equal(results['createRoot + React onClick'], 1);
assert.equal(results['createRoot + setTimeout'], 1);
assert.equal(results['createRoot + promise .then'], 1);
assert.equal(results['createRoot + native listener'], 1);
assert.equal(results['createRoot + flushSync between'], 2);
assert.equal(results['legacy ReactDOM.render + onClick'], 1);
assert.equal(results['legacy ReactDOM.render + setTimeout'], 2);
assert.equal(results['legacy ReactDOM.render + promise'], 2);
// Reading state from the closure versus using an updater, with batching on.
const host = document.createElement('div'); document.body.appendChild(host);
const root = createRoot(host); root.render(h(Counter)); await tick();
api.staleTwice(); await tick();
const afterStale = host.firstChild.dataset.count;
api.updaterTwice(); await tick();
const afterUpdater = host.firstChild.dataset.count;
console.log('count after staleTwice:', afterStale, ' after updaterTwice:', afterUpdater);
assert.equal(afterStale, '1'); assert.equal(afterUpdater, '3');
root.unmount();
console.log('all assertions passed');
Run in a node:22 container (Node 22.23.3, React 18.3.1, jsdom), it prints:
createRoot + React onClick 2 setState calls -> 1 render(s), label 1:true
createRoot + setTimeout 2 setState calls -> 1 render(s), label 1:true
createRoot + promise .then 2 setState calls -> 1 render(s), label 1:true
createRoot + native listener 2 setState calls -> 1 render(s), label 1:true
createRoot + flushSync between 2 setState calls -> 2 render(s), label 1:true
legacy ReactDOM.render + onClick 2 setState calls -> 1 render(s), label 1:true
legacy ReactDOM.render + setTimeout 2 setState calls -> 2 render(s), label 1:true
legacy ReactDOM.render + promise 2 setState calls -> 2 render(s), label 1:true
legacy warning starts: Warning: ReactDOM.render is no longer supported in React 18.
count after staleTwice: 1 after updaterTwice: 3
all assertions passed
Reading it: with createRoot every trigger gives 1 render, except the flushSync case, which gives 2 because it asks React to render after the first setter before the second runs. With the legacy root, the onClick case is 1 but the setTimeout and promise cases are 2, which is the behaviour in the question. The same component, trigger and setters give 1 render under createRoot and 2 under the legacy root for the setTimeout and promise cases, so the root type is what changed the result.
The part that surprises people: batching does not update the variable you just set
The count after line of the output shows a second consequence of queuing. staleTwice calls setCount(count + 1) twice; both calls read the same count from the render's closure, so the result is 1 after both. updaterTwice passes functions, setCount(c => c + 1), and each function receives the value left by the previous queued update, so the result goes from 1 to 3. Use the updater form whenever the new value depends on the old one.
Trade-offs and pitfalls
- Upgrading the package is not enough. Switching
ReactDOM.rendertocreateRootis what enables the new behaviour, and the legacy call also prints a warning that it is no longer supported in React 18. - Code that relied on a synchronous render after a setter in a timer (reading the DOM right after
setState) can break after the switch. If you genuinely need the DOM updated first, wrap that one update influshSync, and expect it to cost an extra render. - Do not count on batching for correctness. Write components so that any sequence of renders is valid, because how many renders happen is React's decision, not part of your component's contract.
- Tests: assert with a render counter as in the demo, or with the React DevTools Profiler, not with timing.
Running the code
mkdir batching && cd batching && printf '{"type":"module"}' > package.json
npm i react@18.3.1 react-dom@18.3.1 @testing-library/react@16.0.1 @testing-library/dom@10.4.0 jsdom@25.0.1
node batching.mjs # the listing above
Unlock Full Question Bank
Get access to all Frontend Component and State Architecture interview questions and detailed answers.
Sign in to ContinueJoin thousands of developers preparing for their dream job.