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.
Widgets in a large single-page app that share no parent need to notify each other (a toast service, a global filter, a selection change). Sketch a small event bus for this: its API, how listeners are cleaned up so nothing leaks, how one-off listeners and grouping of events work, and what you do when an event fires hundreds of times a second.
Sample Answer
Direct answer
A small event bus is a Map from event name to a Set of handlers, with four rules that decide whether it is safe in a large app: (1) every subscription can be undone, one at a time or as a group; (2) delivery works on a copy of the handler list and isolates errors per handler; (3) once handlers remove themselves before running; (4) hot events are coalesced. on returns its own unsubscribe function, and also accepts an AbortSignal (the browser's standard cancellation object) so that all of a component's subscriptions end with one abort(). Hot events are not delivered when they happen: emitCoalesced queues them, and one scheduled flush later calls emit once, with either the newest payload only or an array of all payloads. The tests below exercise each rule, and a mutation run shows that removing each rule makes a test fail.
API
| Call | Behaviour |
|---|---|
bus.on(pattern, handler, { signal }) | Subscribe to "toast:show" (exact) or "toast:*" (every event in the toast namespace, meaning the part of the event name before the first colon). Returns an unsubscribe function. Aborting signal unsubscribes too |
bus.once(pattern, handler) | Like on, but the handler removes itself first, then runs |
bus.off(pattern, handler) | Remove one handler; deletes the Set when it becomes empty so the Map does not grow |
bus.emit(type, payload) | Deliver now, to the exact handlers and then the namespace handlers, in subscription order |
bus.emitCoalesced(type, payload, mode) | Queue; the first call in a flush window schedules one flush. 'latest' delivers the newest payload, 'all' delivers an array |
bus.listenerCount(pattern?) | For tests and leak checks |
useBusEvent(bus, pattern, handler) | React hook: subscribe in an effect (code that runs after the component is shown), unsubscribe in the effect's cleanup |
Leak prevention
- Every
onreturns a function that undoes it, so the caller never needs to keep the handler. - One
AbortControllerper component or feature lifetime:abort()removes all its subscriptions at once. The test subscribes two events with one signal and one without, aborts, and sees one listener left. - The React hook subscribes in
useEffectand aborts in the cleanup. It keeps the newest handler in a ref (a mutable box that survives re-renders), so a new handler function on each render does not cause an unsubscribe and resubscribe. - Empty Sets are deleted, and
listenerCount()returns 0 after unmount, which is what tests should assert.
High-frequency events
500 emit calls would run every handler 500 times, and for a UI each run can mean a re-render. emitCoalesced collects the calls during the flush window (the time between the first call and the scheduled flush) until the next scheduled flush (a microtask here: a small job the JavaScript engine runs as soon as the current code finishes, before timers and before the browser paints; pass requestAnimationFrame as schedule in a browser to deliver at most once per frame). Use 'latest' for state-like events (mouse position, scroll offset) where only the newest value matters, and 'all' where each payload matters (log lines, analytics) and the handler can process a batch.
Implementation and tests
// event-bus.mjs
import { useEffect, useRef } from 'react';
// Event types are strings like "toast:show". The part before the first colon is the namespace,
// and "toast:*" subscribes to every event in that namespace.
export class EventBus {
constructor({ schedule = queueMicrotask, onError = (err, type) => console.error(`handler for ${type} threw`, err) } = {}) {
this.handlers = new Map(); // pattern -> Set of handlers
this.queued = new Map(); // type -> { mode, payloads }, for events that fire very often
this.schedule = schedule; // how a batch is flushed: queueMicrotask here, requestAnimationFrame in a browser
this.onError = onError;
}
// Subscribe. Returns an unsubscribe function. Passing an AbortSignal ties many subscriptions to one lifetime.
on(pattern, handler, { signal } = {}) {
if (signal?.aborted) return () => {};
if (!this.handlers.has(pattern)) this.handlers.set(pattern, new Set());
this.handlers.get(pattern).add(handler);
const off = () => this.off(pattern, handler);
signal?.addEventListener('abort', off, { once: true });
return off;
}
once(pattern, handler, opts) {
const off = this.on(pattern, (payload, type) => { off(); handler(payload, type); }, opts); // removed BEFORE it runs
return off;
}
off(pattern, handler) {
const set = this.handlers.get(pattern);
if (!set) return;
set.delete(handler);
if (set.size === 0) this.handlers.delete(pattern); // do not keep empty sets: the Map would grow forever
}
listenerCount(pattern) {
if (pattern) return this.handlers.get(pattern)?.size ?? 0;
let n = 0; for (const set of this.handlers.values()) n += set.size; return n;
}
// Deliver now. The list of handlers is copied first, so handlers added or removed during delivery
// affect the next emit, not this one, and one throwing handler does not stop the others.
emit(type, payload) {
const ns = type.split(':')[0];
const targets = [...(this.handlers.get(type) ?? []), ...(this.handlers.get(`${ns}:*`) ?? [])];
for (const handler of targets) {
try { handler(payload, type); } catch (err) { this.onError(err, type); }
}
}
// For events that can fire hundreds of times a second. Nothing is delivered synchronously; the first call
// schedules one flush, and the flush delivers either only the newest payload ('latest') or all of them ('all', as an array).
emitCoalesced(type, payload, mode = 'latest') {
let q = this.queued.get(type);
if (!q) {
q = { mode, payloads: [] };
this.queued.set(type, q);
this.schedule(() => {
this.queued.delete(type);
this.emit(type, q.mode === 'latest' ? q.payloads.at(-1) : q.payloads);
});
}
q.payloads.push(payload);
}
}
// React adapter: subscribe in an effect, unsubscribe in its cleanup, and always call the newest handler
// without resubscribing on every render.
export function useBusEvent(bus, pattern, handler) {
const latest = useRef(handler);
latest.current = handler;
useEffect(() => {
const controller = new AbortController();
bus.on(pattern, (payload, type) => latest.current(payload, type), { signal: controller.signal });
return () => controller.abort();
}, [bus, pattern]);
}
// 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;
// event-bus.test.mjs
import './setup.mjs';
import assert from 'node:assert/strict';
import React, { StrictMode, useState } from 'react';
import { render, screen, act, cleanup } from '@testing-library/react';
import { EventBus, useBusEvent } from './event-bus.mjs';
const h = React.createElement;
const errors = [];
const mk = () => new EventBus({ onError: (e) => errors.push(e.message) });
// --- plain bus behaviour
{
const bus = mk(); const seen = [];
const off = bus.on('toast:show', (p) => seen.push(['exact', p]));
bus.on('toast:*', (p, t) => seen.push(['ns', t, p]));
bus.emit('toast:show', 'saved'); bus.emit('toast:hide', 'x'); bus.emit('filter:change', 'y');
assert.deepEqual(seen, [['exact', 'saved'], ['ns', 'toast:show', 'saved'], ['ns', 'toast:hide', 'x']]);
off(); bus.emit('toast:show', 'again');
assert.equal(seen.length, 4, 'after off() only the namespace handler hears it');
console.log('pass: exact and namespace subscriptions');
}
{
const bus = mk(); let calls = 0;
bus.once('sel:change', () => { calls++; bus.emit('sel:change', 'nested'); }); // re-entrant emit from inside a once handler
bus.emit('sel:change', 1);
assert.equal(calls, 1, 'a once handler runs exactly once, even if it emits the same event');
assert.equal(bus.listenerCount(), 0);
console.log('pass: once, with a re-entrant emit');
}
{
const bus = mk(); const order = [];
bus.on('a:x', () => { order.push('first'); bus.on('a:x', () => order.push('added during emit')); });
bus.on('a:x', () => { throw new Error('boom'); });
bus.on('a:x', () => order.push('third'));
bus.emit('a:x');
assert.deepEqual(order, ['first', 'third'], 'a throwing handler does not stop the others; a handler added mid-emit waits for the next emit');
assert.deepEqual(errors, ['boom']);
console.log('pass: snapshot delivery and error isolation');
}
{
const bus = mk(); const controller = new AbortController();
bus.on('a:x', () => {}, { signal: controller.signal });
bus.on('a:y', () => {}, { signal: controller.signal });
bus.on('b:z', () => {});
assert.equal(bus.listenerCount(), 3);
controller.abort();
assert.equal(bus.listenerCount(), 1, 'one abort removes every subscription of that group');
assert.equal(bus.handlers.has('a:x'), false, 'empty sets are deleted');
console.log('pass: group teardown with one AbortSignal');
}
// --- hot events
async function hot(emitHot) {
const bus = mk(); const got = [];
bus.on('mouse:move', (p) => got.push(p));
for (let i = 1; i <= 500; i++) emitHot(bus, i);
await Promise.resolve(); // let the scheduled flush run
return got;
}
const latest = await hot((bus, i) => bus.emitCoalesced('mouse:move', i));
assert.deepEqual(latest, [500]);
const all = await (async () => { const bus = mk(); const got = []; bus.on('log:line', (p) => got.push(p.length));
for (let i = 0; i < 500; i++) bus.emitCoalesced('log:line', i, 'all'); await Promise.resolve(); return got; })();
assert.deepEqual(all, [500]);
console.log('pass: 500 hot events become 1 delivery (newest payload) or 1 delivery of 500 payloads');
await assert.rejects(() => hot((bus, i) => bus.emit('mouse:move', i)).then((g) => assert.deepEqual(g, [500])), (e) => e.actual.length === 500);
console.log('fails without coalescing: 500 deliveries');
// --- React: cleanup, StrictMode and the newest handler
const bus = mk();
let subscribeCalls = 0; const realOn = bus.on.bind(bus); bus.on = (...args) => { subscribeCalls++; return realOn(...args); };
function Toast() { const [msg, setMsg] = useState('none'); useBusEvent(bus, 'toast:show', setMsg); return h('p', { 'data-testid': 'toast' }, msg); }
function Counter({ step }) { const [n, setN] = useState(0); useBusEvent(bus, 'tick:*', () => setN((x) => x + step)); return h('p', { 'data-testid': 'count' }, n); }
const r = render(h(StrictMode, null, h(Toast), h(Counter, { step: 1 })));
assert.equal(bus.listenerCount(), 2, 'StrictMode mounts, unmounts and mounts again; the net result is one subscription per component');
act(() => bus.emit('toast:show', 'saved')); assert.equal(screen.getByTestId('toast').textContent, 'saved');
const subscribesBefore = subscribeCalls;
r.rerender(h(StrictMode, null, h(Toast), h(Counter, { step: 5 })));
assert.equal(subscribeCalls, subscribesBefore, 'a new handler function does not resubscribe');
assert.equal(bus.listenerCount(), 2);
act(() => bus.emit('tick:a')); assert.equal(screen.getByTestId('count').textContent, '5', 'the newest handler (step 5) ran');
r.unmount();
assert.equal(bus.listenerCount(), 0, 'unmount removes every subscription');
console.log('pass: React hook cleanup, StrictMode and newest handler');
function LeakyToast() { const [m, setM] = useState(''); React.useEffect(() => { bus.on('toast:show', setM); }, []); return h('p', null, m); }
cleanup();
const leaky = render(h(LeakyToast)); leaky.unmount();
await assert.rejects(async () => assert.equal(bus.listenerCount(), 0), (e) => e.actual === 1 && e.expected === 0);
console.log('fails without cleanup: the listener outlives the component');
console.log('ALL OK');
Run in a node:22 container (Node 22), it prints:
pass: exact and namespace subscriptions
pass: once, with a re-entrant emit
pass: snapshot delivery and error isolation
pass: group teardown with one AbortSignal
pass: 500 hot events become 1 delivery (newest payload) or 1 delivery of 500 payloads
fails without coalescing: 500 deliveries
pass: React hook cleanup, StrictMode and newest handler
fails without cleanup: the listener outlives the component
ALL OK
What breaks if a rule is removed
The two fails without ... lines in the test show that the test can tell a bus without coalescing, and an effect without cleanup, from the real ones. The script below removes one rule at a time from the bus itself: it copies the bus into a temporary folder with one rule removed (the from text replaced by the to text), runs the same test file against the copy, and prints the first error line. If a mutant still passes, the script exits with status 1.
// mutations.mjs
// For each rule, copy the bus into a temp folder with that rule removed, run the same test file against it, and print the first error line.
import { readFileSync, writeFileSync, mkdirSync, copyFileSync, rmSync, symlinkSync } from 'node:fs';
import { spawnSync } from 'node:child_process';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
const src = readFileSync('event-bus.mjs', 'utf8');
const mutants = {
'once removes itself first': ['{ off(); handler(payload, type); }', '{ handler(payload, type); off(); }'],
'delivery on a copy': [
"const targets = [...(this.handlers.get(type) ?? []), ...(this.handlers.get(`${ns}:*`) ?? [])];",
"const targets = (function* (bus) { yield* (bus.handlers.get(type) ?? []); yield* (bus.handlers.get(`${ns}:*`) ?? []); })(this);"],
'error isolation': ['try { handler(payload, type); } catch (err) { this.onError(err, type); }', 'handler(payload, type);'],
'abort signal teardown': ["signal?.addEventListener('abort', off, { once: true });", ''],
'empty sets deleted': ['if (set.size === 0) this.handlers.delete(pattern);', ''],
'hook calls the newest handler': ['latest.current = handler;', ''],
'hook does not resubscribe per render': ['}, [bus, pattern]);', '}, [bus, pattern, handler]);'],
'unsubscribe function works': ['const off = () => this.off(pattern, handler);', 'const off = () => {};'],
'coalescing delivers the newest payload': ['q.payloads.at(-1)', 'q.payloads[0]'],
};
for (const [name, [from, to]] of Object.entries(mutants)) {
if (!src.includes(from)) throw new Error('mutation target not found: ' + name);
const dir = join(tmpdir(), 'bus-mutant-' + process.pid);
rmSync(dir, { recursive: true, force: true }); mkdirSync(dir);
writeFileSync(join(dir, 'event-bus.mjs'), src.replace(from, to));
for (const f of ['setup.mjs', 'event-bus.test.mjs']) copyFileSync(f, join(dir, f));
symlinkSync(join(process.cwd(), 'node_modules'), join(dir, 'node_modules'));
const r = spawnSync('node', ['event-bus.test.mjs'], { cwd: dir, encoding: 'utf8' });
const line = (r.stderr.split('\n').find((l) => /^\s*\w*Error(\s\[\w+\])?:/.test(l)) ?? '').trim().slice(0, 140);
console.log(`without "${name}": exit ${r.status} | ${line}`);
if (r.status === 0) { console.log(' ^ the test still passed, so this rule is not covered'); process.exitCode = 1; }
rmSync(dir, { recursive: true, force: true });
}
Run in the same node:22 container after the test (node mutations.mjs), it prints:
without "once removes itself first": exit 1 | AssertionError [ERR_ASSERTION]: a once handler runs exactly once, even if it emits the same event
without "delivery on a copy": exit 1 | AssertionError [ERR_ASSERTION]: a throwing handler does not stop the others; a handler added mid-emit waits for the next emit
without "error isolation": exit 1 | Error: boom
without "abort signal teardown": exit 1 | AssertionError [ERR_ASSERTION]: one abort removes every subscription of that group
without "empty sets deleted": exit 1 | AssertionError [ERR_ASSERTION]: empty sets are deleted
without "hook calls the newest handler": exit 1 | AssertionError [ERR_ASSERTION]: the newest handler (step 5) ran
without "hook does not resubscribe per render": exit 1 | AssertionError [ERR_ASSERTION]: a new handler function does not resubscribe
without "unsubscribe function works": exit 1 | AssertionError [ERR_ASSERTION]: after off() only the namespace handler hears it
without "coalescing delivers the newest payload": exit 1 | AssertionError [ERR_ASSERTION]: Expected values to be strictly deep-equal:
Reading it: the number after exit is the process status, and 1 means the test file failed, which is what each mutant should cause. Removing the error isolation shows Error: boom because the throwing handler now escapes emit and crashes the test instead of failing an assertion. In the delivery mutant, emit walks the live Set, so a handler added during the emit is called in the same emit. Every rule in the direct answer, and the hook's no-resubscribe property, therefore has a printed failing run: the unsubscribe function, the copy and per-handler error isolation, once, the abort signal, coalescing and the hook. The test counts calls to bus.on around a re-render, because listenerCount() stays 2 whether or not the hook unsubscribed and subscribed again.
Complexity and edge cases
on,offandonceare O(1) (constant cost, however many handlers exist).emitis O(h) (cost grows in proportion to h) for the h handlers of that event plus its namespace. A coalesced event costs O(1) per call plus one delivery per flush.- A handler that throws is reported through
onErrorand the remaining handlers still run (tested). - A handler added during an
emitis not called for that emit (tested); aoncehandler that re-emits its own event (a re-entrant emit: anemitcalled from inside a handler thatemititself is running) runs exactly once (tested). - StrictMode (React's development check that mounts, unmounts and mounts a component again) ends with one subscription per component (tested).
- In the hot-event test,
await Promise.resolve()pauses the test until the microtask queue has run. The flush was queued byqueueMicrotaskearlier, so it runs before the test continues, and the check sees the delivered result. - A listener count above zero after unmount is a leak, and the last test shows an effect without cleanup leaving one behind.
Trade-offs
A bus decouples sender and receiver, which is also its cost: nothing in the code shows who listens, and event names are untyped strings. Use it for fire-and-forget notifications between unrelated widgets (toasts, analytics). For state that widgets read (a filter, a selection) prefer a store or lifted state so that new components can read the current value; an event only reaches handlers that are already subscribed. In TypeScript, type the event map so payloads are checked per event name (a recommendation).
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 event-bus.test.mjs # Node 22; setup.mjs and event-bus.mjs in the same folder
node mutations.mjs # removes each rule once and shows the test failing
You are asked to explain how a Redux-style store works by designing a tiny one. What primitives does it need, how would you let side effects such as API calls hook in without polluting reducers, and what does that buy you for debugging?
Sample Answer
A Redux-style store is four primitives wrapped around one rule. The four primitives are getState(), dispatch(action), subscribe(listener) and a reducer, a pure function (state, action) => newState ("pure" means: same inputs give the same output, and it touches nothing outside itself). The rule is that state only changes by dispatching a plain-object action (a record such as { type: 'users/fetchRequested' }) through the reducer. Side effects such as API calls go in middleware, an optional layer added on top of the four primitives that wraps dispatch, so the reducer stays pure and every state change is explained by an entry in an action log. That log is what makes debugging, replay and time travel cheap.
The primitives
The table has six rows: the four primitives, the private state variable they protect, and the middleware layer added on top.
| Primitive | Job | Invariant it protects |
|---|---|---|
state (private variable) | the single current value | only the store reassigns it |
reducer(state, action) | computes the next state | pure, no network, no Date.now(), no dispatching |
dispatch(action) | runs the reducer, then notifies listeners | actions are plain objects with a string type |
getState() | reads the current value | returns the same reference until a dispatch changes it |
subscribe(listener) | registers a callback, returns an unsubscribe function | listeners run after the state is updated |
| middleware | wraps dispatch so code can run before and after the reducer | the reducer never sees anything but plain actions |
Side effects without polluting reducers
Middleware has the shape ({ getState, dispatch }) => next => action => result. That is a curried function (one that takes its arguments one at a time and returns a new function after each): the outer call receives the store's getState and dispatch, the next call receives next, the next layer inward (finally the real dispatch), and the innermost function runs once per dispatched action. The flow for a fetch:
- The UI dispatches
users/fetchRequested. - The effects middleware calls
next(action)first, so the reducer recordsstatus: 'loading'. - The middleware then starts the request. When it settles, it dispatches a second plain action,
fetchSucceededorfetchFailed. - The reducer handles those like any other action.
Network results therefore enter the store as data, and the reducer stays a pure function of (state, action). The API object is injected into the middleware (effects(api)), which is also what makes it testable.
What this buys for debugging
- A log of what happened. A
recordermiddleware appends every action to an array. State isactions.reduce(reducer, undefined), so a bug report can ship the log. - Replay without side effects. Feeding the logged actions to a store that has no effects middleware rebuilds the same state and makes no network calls. If the fetch lived inside the reducer, every replay would call the API again.
- Time travel. The state after the first
kactions islog.slice(0, k).reduce(reducer, undefined). - Guardrails. The store throws when an action has no string
typeand when a reducer tries to dispatch (the second is tested), which are two ways a pure function quietly stops being pure.
Persistence and time travel interact. Persisting state from a subscribe listener while scrubbing the timeline would overwrite the saved state with an old snapshot. The safer design (a recommendation, not something the code below exercises) is to persist only on real dispatches, and to run time travel against a separate read-only view of the log, or to persist the action log and rebuild state from it on load.
The code
The store has one extension hook. createStore's third argument, the enhancer, is a function that receives createStore itself and returns a replacement version of it; applyMiddleware builds such an enhancer.
// store.js
export function createStore(reducer, preloaded, enhancer) {
if (enhancer) return enhancer(createStore)(reducer, preloaded);
let state = reducer(preloaded, { type: '@@init' });
const listeners = new Set();
let dispatching = false;
return {
getState: () => state,
dispatch(action) {
if (typeof action?.type !== 'string') throw new Error('actions must be plain objects with a string type');
if (dispatching) throw new Error('reducers must not dispatch');
dispatching = true;
try { state = reducer(state, action); } finally { dispatching = false; }
listeners.forEach((l) => l());
return action;
},
subscribe(l) { listeners.add(l); return () => listeners.delete(l); },
};
}
// middleware = ({ getState, dispatch }) => next => action => result
export const applyMiddleware = (...mws) => (create) => (reducer, preloaded) => {
const store = create(reducer, preloaded);
let dispatch = () => { throw new Error('dispatch while building middleware'); };
const api = { getState: store.getState, dispatch: (a) => dispatch(a) };
dispatch = mws.map((m) => m(api)).reduceRight((next, mw) => mw(next), store.dispatch);
return { ...store, dispatch };
};
// store.test.js
import { createStore, applyMiddleware } from './store.js';
const initial = { users: { status: 'idle', byId: {} } };
function reducer(state = initial, action) {
switch (action.type) {
case 'users/fetchRequested': return { users: { ...state.users, status: 'loading' } };
case 'users/fetchSucceeded': return { users: { status: 'ready', byId: Object.fromEntries(action.payload.map((u) => [u.id, u])) } };
case 'users/fetchFailed': return { users: { ...state.users, status: 'error' } };
default: return state;
}
}
// effect middleware: the only place that talks to the network
const effects = (api) => ({ dispatch }) => (next) => (action) => {
const result = next(action); // reducer runs first, state already says "loading"
if (action.type === 'users/fetchRequested') {
api.fetchUsers().then(
(users) => dispatch({ type: 'users/fetchSucceeded', payload: users }),
() => dispatch({ type: 'users/fetchFailed' }),
);
}
return result;
};
// debugging middleware: an append-only log of every action
const recorder = (log) => () => (next) => (action) => { log.push(action); return next(action); };
const flush = () => new Promise((r) => setTimeout(r, 0));
const makeApi = () => { const api = { calls: 0, fetchUsers: async () => { api.calls++; return [{ id: 1, name: 'Ada' }]; } }; return api; };
test('reducer is pure and unknown actions keep the same reference', () => {
const s = createStore(reducer);
const before = s.getState();
s.dispatch({ type: 'nothing/happened' });
expect(s.getState()).toBe(before);
});
test('subscribers run after each dispatch and unsubscribe stops them', () => {
const s = createStore(reducer);
let n = 0;
const off = s.subscribe(() => n++);
s.dispatch({ type: 'users/fetchRequested' });
off();
s.dispatch({ type: 'users/fetchFailed' });
expect(n).toBe(1);
});
test('effects live in middleware; the reducer only sees plain actions', async () => {
const api = makeApi(); const log = [];
const s = createStore(reducer, undefined, applyMiddleware(recorder(log), effects(api)));
s.dispatch({ type: 'users/fetchRequested' });
expect(s.getState().users.status).toBe('loading');
await flush();
expect(s.getState().users).toEqual({ status: 'ready', byId: { 1: { id: 1, name: 'Ada' } } });
expect(api.calls).toBe(1);
expect(log.map((a) => a.type)).toEqual(['users/fetchRequested', 'users/fetchSucceeded']);
});
test('failed request becomes a plain action too', async () => {
const api = { fetchUsers: async () => { throw new Error('boom'); } };
const s = createStore(reducer, undefined, applyMiddleware(effects(api)));
s.dispatch({ type: 'users/fetchRequested' });
await flush();
expect(s.getState().users.status).toBe('error');
});
test('replaying the log through a bare store reproduces state with zero network calls', async () => {
const api = makeApi(); const log = [];
const live = createStore(reducer, undefined, applyMiddleware(recorder(log), effects(api)));
live.dispatch({ type: 'users/fetchRequested' });
await flush();
const replay = createStore(reducer); // no effects middleware
log.forEach((a) => replay.dispatch(a));
expect(replay.getState()).toEqual(live.getState());
expect(api.calls).toBe(1); // replay did not touch the API
});
test('time travel: state after the first k actions', async () => {
const api = makeApi(); const log = [];
const live = createStore(reducer, undefined, applyMiddleware(recorder(log), effects(api)));
live.dispatch({ type: 'users/fetchRequested' });
await flush();
const at = (k) => log.slice(0, k).reduce(reducer, undefined);
expect(at(1).users.status).toBe('loading');
expect(at(2).users.status).toBe('ready');
});
test('with the fetch inside the reducer, replay hits the API again', async () => {
const api = makeApi(); const log = [];
const impure = (state = initial, action) => {
if (action.type === 'users/fetchRequested') api.fetchUsers(); // side effect in a reducer
return reducer(state, action);
};
const live = createStore(impure, undefined, applyMiddleware(recorder(log)));
live.dispatch({ type: 'users/fetchRequested' });
const replay = createStore(impure);
log.forEach((a) => replay.dispatch(a));
expect(api.calls).toBe(2); // the contract the middleware version keeps at 1
});
test('a reducer that dispatches is rejected', () => {
const s = createStore((state = 0, action) => { if (action.type === 'a') s.dispatch({ type: 'b' }); return state; });
expect(() => s.dispatch({ type: 'a' })).toThrow('reducers must not dispatch');
});
test('middleware layers nest: A before, B before, reducer, B after, A after', () => {
const trace = [];
const layer = (name) => () => (next) => (action) => { trace.push(name + ' before'); const r = next(action); trace.push(name + ' after'); return r; };
const s = createStore((state = 0, action) => { if (action.type === 'go') trace.push('reducer'); return state; }, undefined, applyMiddleware(layer('A'), layer('B')));
s.dispatch({ type: 'go' });
expect(trace).toEqual(['A before', 'B before', 'reducer', 'B after', 'A after']);
});
test('a middleware that dispatches while the chain is being built gets the placeholder error', () => {
const eager = ({ dispatch }) => { dispatch({ type: 'too-early' }); return (next) => next; };
expect(() => createStore((state = 0) => state, undefined, applyMiddleware(eager))).toThrow('dispatch while building middleware');
});
How applyMiddleware composes the layers, using applyMiddleware(recorder, effects): mws.map((m) => m(api)) calls each middleware once with the store's getState and a dispatch, giving a list of next => action => result functions. reduceRight then folds that list from the right, starting with the real store.dispatch as next: effects wraps the real dispatch, then recorder wraps that result, and the outcome is the new dispatch. An action therefore passes through recorder, then effects, then the reducer, and the results return in the opposite order. The ninth test checks this with two layers, A listed before B, and the trace is A before, B before, reducer, B after, A after. The api.dispatch handed to middleware calls the finished chain through the dispatch variable, so a dispatch made by an effect starts again from the outermost layer; before the chain exists that variable is a placeholder that throws, so a middleware that dispatches while it is being set up gets the error dispatch while building middleware; the tenth test checks that.
Run in a node:22 container (Node 22.23.3) with vitest 2.1.8, the verbose reporter prints the following (the Start at and Duration lines that follow the summary are left out):
RUN v2.1.8 /w
✓ store.test.js > reducer is pure and unknown actions keep the same reference
✓ store.test.js > subscribers run after each dispatch and unsubscribe stops them
✓ store.test.js > effects live in middleware; the reducer only sees plain actions
✓ store.test.js > failed request becomes a plain action too
✓ store.test.js > replaying the log through a bare store reproduces state with zero network calls
✓ store.test.js > time travel: state after the first k actions
✓ store.test.js > with the fetch inside the reducer, replay hits the API again
✓ store.test.js > a reducer that dispatches is rejected
✓ store.test.js > middleware layers nest: A before, B before, reducer, B after, A after
✓ store.test.js > a middleware that dispatches while the chain is being built gets the placeholder error
Test Files 1 passed (1)
Tests 10 passed (10)
The seventh test is the counter-example: with the fetch inside the reducer, the same replay makes the API call count 2 where the middleware version keeps it at 1.
Trade-offs and pitfalls
- Middleware order matters. In
applyMiddleware(recorder, effects)the recorder is the outer layer, so it logs each action as it was dispatched, before the effects layer acts on it. - Async results need guarding. Two overlapping
fetchRequestedactions produce two responses; the reducer or middleware should ignore the stale one (for example by carrying a request id in the action). - Subscribers get no diff.
subscribeonly says "something changed"; consumers readgetState()and compare references, which is why reducers must return the old reference when nothing changed. - Alternatives to this shape. A thunk is middleware that lets you dispatch a function, which receives
dispatchandgetStateand can run async code; a saga is middleware that runs effects as generator functions (functions that can pause and resume) listening for actions; an observable-based middleware expresses effects as streams of actions over time. They all decide how effects are written, and the store contract above stays the same.
Running the code
package.json: {"type":"module"}
vitest.config.js: export default { esbuild: { jsx: 'automatic' }, test: { environment: 'jsdom', globals: true } };
npm i vitest@2.1.8 jsdom@25.0.1
npx vitest run store.test.js --reporter=verbose
You are adding undo and redo to a document editor where each user action changes both application state and what is on screen. How do you model the history, how do you record actions without exhausting memory, and how do you restore a previous state correctly and efficiently?
Sample Answer
Direct answer
Make the editor's screen a pure function (the same input always gives the same output, with no side effects) of one immutable document value (a value that is never changed in place; an edit builds a new value), and keep history as three pieces: past (earlier documents), present, and future (documents that were undone and can be redone). Every user action produces a new document that shares every untouched part with the old one, so a history entry costs memory only for what changed. Restoring means moving one entry from past to present and rendering it. Nothing else is restored by hand, so listeners and DOM nodes cannot disagree with the state, because the UI is rebuilt from the state.
To keep memory bounded, do three things: share structure between entries, coalesce bursts (typing, dragging) into one entry, and cap the number of entries. When the document is too big or too irregular to keep as shared immutable data, store inverse operations instead of whole documents.
Data model
| Piece | Holds | Why |
|---|---|---|
present | { doc, key, at } | the current document, plus the kind of edit (key, for example type:paragraph-7) and its time, used to decide coalescing |
past | earlier entries, oldest first | undo pops the last one |
future | undone entries, nearest first | redo shifts the first one; any new edit empties it |
limit | maximum length of past | bounds memory |
A document is { paragraphs: [{ id, text }, ...] }. Cursor position and selection belong inside the entry too, so that undo puts the caret back where it was (the demo below keeps only the document, to keep the listing short).
Recording actions without exhausting memory
- Structural sharing. An edit builds a new array but reuses every unchanged paragraph object (
{ ...p }only for the paragraph that changed). Two documents then point at the same objects for everything that did not change (structural sharing: the old and new arrays are different, but 999 of their 1000 slots refer to the very same paragraph objects), and a snapshot entry costs the changed paragraph plus a new array of references. - Coalescing. Several events that the user perceives as one action become one entry.
commitreplaces the present entry when the new edit has the samekeyand arrives within 500 ms of the previous one. A pause, or a different kind of edit, starts a new entry. The 500 ms window is a product choice to tune, not a standard. - A cap.
pastis cut to the lastlimitentries, so the oldest edits become unrecoverable by design. - Inverse operations instead of snapshots.
applyOpreturns the new document and the operation that reverses it (insertreturnsremove, and the reverse). The entry then stores one small operation, and undo applies the inverse. This is the right form when the state cannot be shared cheaply or has side effects outside the document, such as a server call that has to be compensated (cancelled by sending a second, reversing call, because the first cannot be taken back) or a canvas object.
Restoring correctly and efficiently
Correctly: undo moves present into the front of future and takes the last past entry as the new present. redo does the reverse. A new commit clears future, because the redo branch no longer describes a reachable next state. Because the UI renders from present.doc, there is no step that re-attaches listeners or re-creates nodes.
Efficiently: after an undo, the new present.doc shares all unchanged paragraphs with the document on screen. A memoized component (React.memo, which skips re-rendering when its props are the same by reference) therefore re-renders only the paragraph that differs.
Demo
The file history.mjs holds the reducer-style functions (each takes the current history and returns the next one, without modifying the old one), setText (the edit that shares structure) and applyOp. The demo measures memory as the number of distinct paragraph objects reachable from the history, compares against deep-cloned snapshots, checks coalescing, the cap, that redo reverses an undo, the redo discard, the inverse-operation round trip, and counts React renders through an edit and an undo.
// history.mjs
// Undo/redo as a reducer over { past, present, future }. Each entry is an immutable document.
export const COALESCE_MS = 500;
export function createHistory(doc, limit = 100) {
return { past: [], present: { doc, key: null, at: 0 }, future: [], limit };
}
// commit: record a new document. Edits with the same key within COALESCE_MS replace the
// present entry instead of pushing a new one, so a burst of typing is one undo step.
export function commit(h, doc, { key = null, at = 0 } = {}) {
const sameBurst = key !== null && h.present.key === key && at - h.present.at <= COALESCE_MS;
const past = sameBurst ? h.past : [...h.past, h.present].slice(-h.limit);
return { ...h, past, present: { doc, key, at }, future: [] }; // a new edit discards the redo branch
}
export function undo(h) {
if (h.past.length === 0) return h;
const present = h.past[h.past.length - 1];
return { ...h, past: h.past.slice(0, -1), present, future: [h.present, ...h.future] };
}
export function redo(h) {
if (h.future.length === 0) return h;
const [present, ...future] = h.future;
return { ...h, past: [...h.past, h.present], present, future };
}
// Document edit that shares every untouched paragraph with the previous document.
export function setText(doc, id, text) {
return { ...doc, paragraphs: doc.paragraphs.map((p) => (p.id === id ? { ...p, text } : p)) };
}
// Inverse-operation form: apply returns the new document and the operation that undoes it.
export function applyOp(doc, op) {
if (op.type === 'insert') {
const paragraphs = [...doc.paragraphs.slice(0, op.index), op.paragraph, ...doc.paragraphs.slice(op.index)];
return [{ ...doc, paragraphs }, { type: 'remove', index: op.index }];
}
if (op.type === 'remove') {
const removed = doc.paragraphs[op.index];
const paragraphs = [...doc.paragraphs.slice(0, op.index), ...doc.paragraphs.slice(op.index + 1)];
return [{ ...doc, paragraphs }, { type: 'insert', index: op.index, paragraph: removed }];
}
throw new Error('unknown op ' + op.type);
}
// history_demo.mjs
import { JSDOM } from 'jsdom';
import assert from 'node:assert/strict';
import { createHistory, commit, undo, redo, setText, applyOp } from './history.mjs';
const dom = new JSDOM('<!doctype html><div id="root"></div>', { pretendToBeVisual: true });
Object.assign(globalThis, { window: dom.window, document: dom.window.document, IS_REACT_ACT_ENVIRONMENT: true });
const React = (await import('react')).default;
const { createRoot } = await import('react-dom/client');
const { act } = await import('react');
const h = React.createElement;
const makeDoc = (n) => ({ paragraphs: Array.from({ length: n }, (_, i) => ({ id: i, text: `para ${i}` })) });
const deepClone = (d) => structuredClone(d);
const out = (k, v) => console.log(k.padEnd(52), v);
// 1. Memory: 1000 paragraphs, 200 edits of different paragraphs, no coalescing.
function distinctParagraphObjects(hist) {
const all = new Set();
for (const e of [...hist.past, hist.present, ...hist.future]) for (const p of e.doc.paragraphs) all.add(p);
return all.size;
}
let shared = createHistory(makeDoc(1000), 1000), cloned = createHistory(makeDoc(1000), 1000);
for (let i = 0; i < 200; i++) {
shared = commit(shared, setText(shared.present.doc, i, `edited ${i}`), { key: `p${i}`, at: i * 1000 });
cloned = commit(cloned, setText(deepClone(cloned.present.doc), i, `edited ${i}`), { key: `p${i}`, at: i * 1000 });
}
const sharedCount = distinctParagraphObjects(shared), clonedCount = distinctParagraphObjects(cloned);
out('entries in history (past + present):', shared.past.length + 1);
out('paragraph objects held, structural sharing:', sharedCount);
out('paragraph objects held, deep-cloned snapshots:', clonedCount);
assert.equal(shared.past.length, 200);
assert.equal(sharedCount, 1000 + 200); // 1000 originals + one new object per edit
assert.equal(clonedCount, 201 * 1000); // every snapshot owns its own copy of every paragraph
// 2. Coalescing: 20 keystrokes 100 ms apart are one entry; a pause starts a new one.
let t = createHistory(makeDoc(3));
let text = '';
for (let i = 0; i < 20; i++) { text += 'x'; t = commit(t, setText(t.present.doc, 1, text), { key: 'type:1', at: i * 100 }); }
out('undo steps after 20 keystrokes 100 ms apart:', t.past.length);
assert.equal(t.past.length, 1);
t = commit(t, setText(t.present.doc, 1, text + 'y'), { key: 'type:1', at: 19 * 100 + 600 });
out('undo steps after a 600 ms pause and one more key:', t.past.length);
assert.equal(t.past.length, 2);
assert.equal(undo(undo(t)).present.doc.paragraphs[1].text, 'para 1');
// 3. Cap: limit 50, 200 edits.
let c = createHistory(makeDoc(300), 50);
for (let i = 0; i < 200; i++) c = commit(c, setText(c.present.doc, i, `e${i}`), { key: `p${i}`, at: i * 1000 });
let back = c; let steps = 0;
while (back.past.length) { back = undo(back); steps++; }
out('undo steps available with limit 50:', steps);
out('oldest reachable state: paragraph 149 reads:', back.present.doc.paragraphs[149].text);
assert.equal(steps, 50);
assert.equal(back.present.doc.paragraphs[149].text, 'e149');
assert.equal(back.present.doc.paragraphs[150].text, 'para 150');
// 4. Redo reverses an undo, and a new edit discards the redo branch.
let r = commit(createHistory(makeDoc(2)), setText(makeDoc(2), 0, 'a'), { key: 'k1', at: 0 });
r = undo(r);
assert.equal(r.future.length, 1);
const redone = redo(r); // redo is the reverse move
assert.equal(redone.present.doc.paragraphs[0].text, 'a');
assert.equal(redone.past.length, 1);
assert.equal(redone.future.length, 0);
assert.equal(undo(redone).present, r.present);
r = commit(r, setText(r.present.doc, 1, 'b'), { key: 'k2', at: 5000 });
assert.equal(r.future.length, 0);
assert.equal(redo(r), r);
// 5. Inverse operations round-trip a mixed sequence back to the start.
let doc = makeDoc(5); const start = doc; const inverses = [];
for (const op of [
{ type: 'insert', index: 2, paragraph: { id: 90, text: 'new' } },
{ type: 'remove', index: 0 },
{ type: 'insert', index: 4, paragraph: { id: 91, text: 'tail' } },
{ type: 'remove', index: 1 },
]) { const [next, inv] = applyOp(doc, op); doc = next; inverses.push(inv); }
for (const inv of inverses.reverse()) doc = applyOp(doc, inv)[0];
assert.deepEqual(doc, start);
out('inverse ops restore the original document:', 'yes');
// 6. The screen follows the state: render counts per paragraph through an edit and an undo.
const renders = new Map();
const Para = React.memo(function Para({ p }) {
renders.set(p.id, (renders.get(p.id) ?? 0) + 1);
return h('p', { 'data-id': p.id }, p.text);
});
function Editor({ hist }) {
return h('div', null, hist.present.doc.paragraphs.map((p) => h(Para, { key: p.id, p })));
}
async function renderCounts(label, makeNext) {
renders.clear();
const host = document.getElementById('root'); host.innerHTML = '';
const root = createRoot(host);
let hist = createHistory(makeDoc(50));
await act(async () => root.render(h(Editor, { hist })));
const total = () => [...renders.values()].reduce((a, b) => a + b, 0);
const mount = total();
hist = commit(hist, makeNext(hist.present.doc), { key: 'k', at: 0 });
await act(async () => root.render(h(Editor, { hist })));
const afterEdit = total() - mount;
hist = undo(hist);
await act(async () => root.render(h(Editor, { hist })));
const afterUndo = total() - mount - afterEdit;
out(`${label}: paragraph renders on edit / undo:`, `${afterEdit} / ${afterUndo}`);
out(`${label}: DOM text of paragraph 7 after undo:`, host.querySelector('[data-id="7"]').textContent);
await act(async () => root.unmount());
return [afterEdit, afterUndo];
}
const a = await renderCounts('structural sharing', (d) => setText(d, 7, 'changed'));
const b = await renderCounts('deep-cloned snapshots', (d) => setText(deepClone(d), 7, 'changed'));
assert.deepEqual(a, [1, 1]);
assert.deepEqual(b, [50, 50]);
console.log('all assertions passed');
Output:
entries in history (past + present): 201
paragraph objects held, structural sharing: 1200
paragraph objects held, deep-cloned snapshots: 201000
undo steps after 20 keystrokes 100 ms apart: 1
undo steps after a 600 ms pause and one more key: 2
undo steps available with limit 50: 50
oldest reachable state: paragraph 149 reads: e149
inverse ops restore the original document: yes
structural sharing: paragraph renders on edit / undo: 1 / 1
structural sharing: DOM text of paragraph 7 after undo: para 7
deep-cloned snapshots: paragraph renders on edit / undo: 50 / 50
deep-cloned snapshots: DOM text of paragraph 7 after undo: para 7
all assertions passed
Reading the numbers: 1000 paragraphs and 200 edits of different paragraphs give 1000 original objects plus 200 edited ones, 1200, with structural sharing. Deep-cloned snapshots give 201 entries of 1000 paragraphs each, 201,000. The counts are objects, not bytes, but both entries hold the same kind of object. Rendering 50 paragraphs, one edit and one undo each re-render exactly 1 paragraph with sharing, and all 50 with cloned snapshots.
What breaks if a technique is removed
The script below copies history.mjs into a temporary folder with one technique removed (the from text replaced by the to text), runs history_demo.mjs against the copy, and prints the first failing assertion. It exits with status 1 if a mutant still passes. Deep-cloning inside the edit is already compared in the demo itself (the deep-cloned snapshots lines: 50 paragraph renders per edit instead of 1).
// mutations.mjs
// Copy history.mjs with one technique removed, run the demo against the copy, and print the first failing assertion.
import { readFileSync, writeFileSync, mkdirSync, copyFileSync, rmSync, symlinkSync } from 'node:fs';
import { spawnSync } from 'node:child_process';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
const src = readFileSync('history.mjs', 'utf8');
const mutants = {
'coalescing': ['const sameBurst = key !== null && h.present.key === key && at - h.present.at <= COALESCE_MS;', 'const sameBurst = false;'],
'redo moving the entry back': ['return { ...h, past: [...h.past, h.present], present, future };', 'return h;'],
'the cap': ['.slice(-h.limit)', ''],
'discarding the redo branch': ['present: { doc, key, at }, future: [] }', 'present: { doc, key, at }, future: h.future }'],
};
let survived = 0;
for (const [name, [from, to]] of Object.entries(mutants)) {
if (!src.includes(from)) throw new Error('mutation target not found: ' + name);
const dir = join(tmpdir(), 'history-mutant-' + process.pid);
rmSync(dir, { recursive: true, force: true }); mkdirSync(dir);
writeFileSync(join(dir, 'history.mjs'), src.replace(from, to));
copyFileSync('history_demo.mjs', join(dir, 'history_demo.mjs'));
symlinkSync(join(process.cwd(), 'node_modules'), join(dir, 'node_modules'));
const r = spawnSync('node', ['history_demo.mjs'], { cwd: dir, encoding: 'utf8' });
const lines = r.stderr.split('\n');
const i = lines.findIndex((l) => /^AssertionError/.test(l));
const detail = i >= 0 ? lines.slice(i + 1).find((l) => /!==|!=/.test(l)) ?? '' : lines.find((l) => /Error/.test(l)) ?? '';
console.log(`without ${name}: exit ${r.status} | ${i >= 0 ? lines[i].replace(/:.*$/, '') : ''} ${detail.trim()}`);
if (r.status === 0) survived++;
rmSync(dir, { recursive: true, force: true });
}
if (survived) { console.log(survived + ' mutant(s) still passed'); process.exit(1); }
Run in the same node:22 container (Node 22.23.3) after the demo, node mutations.mjs prints:
without coalescing: exit 1 | AssertionError [ERR_ASSERTION] 20 !== 1
without redo moving the entry back: exit 1 | AssertionError [ERR_ASSERTION] 'para 0' !== 'a'
without the cap: exit 1 | AssertionError [ERR_ASSERTION] 200 !== 50
without discarding the redo branch: exit 1 | AssertionError [ERR_ASSERTION] 1 !== 0
Reading it: each line is actual !== expected for the first failing assertion. Without coalescing, 20 keystrokes leave 20 undo steps where 1 is expected; without the cap, 200 undo steps are available where 50 is expected; without clearing future, the redo list still holds 1 entry after a new edit where 0 is expected; with redo replaced by a function that returns its input, the redone document still reads para 0 where a is expected.
Trade-offs and pitfalls
- Snapshots versus operations. Snapshots with sharing are simple to get right: undo cannot drift, because each entry is a complete document. Inverse operations cost less per entry and suit collaborative or server-backed editing, but a bug in one inverse corrupts every later undo, so test them in round trips like the one in the demo.
- Do not put derived or transient state in the history. Hover state, scroll position and "is the menu open" should not be undoable. Record document content and, optionally, selection.
- Undo must not be recorded as an edit.
undoandredomove entries without callingcommit; going throughcommitwould clearfutureand make redo impossible. - External resources. Anything not held in the document (uploaded files, canvas bitmaps) needs a reference-counting rule (keep a count of how many history entries mention the resource, and free it when the count reaches 0), or old entries keep it alive.
- Collaborative editing changes the question. Undo should usually reverse the current user's own last change, not the document's last change, which a single linear history cannot do.
Running the code
npm init -y && npm pkg set type=module
npm i react@18.3.1 react-dom@18.3.1 jsdom@25.0.1
node history_demo.mjs # the demo
node mutations.mjs # removes each technique once and shows the failing assertion
Run in a node:22 container (Node 22.23.3). Both files sit in the same directory.
That is every published Frontend Component and State Architecture question for Software Engineer so far. Browse the other topics in this category, or practice this one interactively.