Kata: tiny reactive store (subscribe + select)
Replace the snapshot, never edit it: compare before you build, swap before you call, and let select remember the slice it last reported.
Predict first
Before you read: predict
ExamHall's exam page keeps the connection status, the unsaved-answer flag and the draft in one store, a kata answer the team merged. The save badge listens through select, and log(line) records one line:
const exam = createStore({ connection: 'online', unsaved: false, draft: '' });
exam.select((s) => s.unsaved, (now, before) => log(`${before} -> ${now}`));
exam.setState({ connection: 'offline' });
exam.setState({ draft: 'A', unsaved: true });
exam.setState({ connection: 'online' });
Which lines does the log show?
export function createStore(initial) { let state = initial; const subs = new Set(); function setState(patch) { const prev = state; const next = Object.assign({}, prev, patch); state = next; subs.forEach((fn) => fn(next, prev)); } function select(pick, onChange) { let seen; const fn = (next) => { const now = pick(next); if (Object.is(now, seen)) return; const before = seen; seen = now; onChange(now, before); }; subs.add(fn); return () => subs.delete(fn); } return { getState: () => state, setState, select };}The 30-second version
A kata store keeps one snapshot and never edits it. setState returns at once when no key of the patch differs by Object.is; otherwise it builds { ...state, ...patch }, swaps it in, then calls each listener with the live snapshot and the one before. select reads its slice when it subscribes and fires only when that slice changes.
Replace the snapshot, never edit it: compare before you build, swap before you call, and let select remember the slice it last reported.
How it works
Step 1 of 11
select? Then: one snapshot, replaced on a real change; listeners get (next, prev); select fires when its slice changes.Analogy: A gallery's printed price list (fine art)
During a show, a gallery's front desk holds one printed price list, a line per work. Collectors who asked are phoned when it changes.
| At the gallery | In the kata |
|---|---|
| a sale means setting a new sheet from the old sheet's lines plus the changed line | { ...state, ...patch }; untouched keys shared (step 5) |
| the old sheet goes into the show file as it was | the previous snapshot, intact (step 6) |
| a sale reported twice: no new sheet, no calls | the per-key check (step 4) |
| the new sheet goes on the desk before anyone is phoned | swap, then notify (step 7) |
| a collector who follows one artist has her lines noted when she signs up, and is phoned only when they differ from the last lines she was told | select seeds, compares, remembers (step 9) |
| a red dot inked onto the sheet already on the desk: the filed copy and the desk copy are one sheet | an edited snapshot (step 6) |
In real terms: a store is one variable that is only ever reassigned, plus rules for when to reassign it and whom to tell.
Breaks down when: a phone call happens later and the collector may not answer; a listener runs before setState returns. A printed line also sits on one sheet, while one reference can sit in any number of snapshots.
Contrasting case: a whiteboard by the door, rubbed out and rewritten. No old version exists to compare with: that is a mutable object.
Examples, run for real
4.1 ExamHall EXH-2830: the save badge that rendered on every keystroke
The exam page keeps the connection status, the unsaved-answer flag and the draft in one store built from the reference. Its save badge read the whole snapshot, so each keystroke re-rendered it: the INP incident EXH-2830 (counted here as renders; INP itself is not measured). MR !3130 selects the flag instead.
// before MR !3130
const { unsaved } = useSyncExternalStore(exam.subscribe, exam.getState);
// MR !3130
const unsaved = useSyncExternalStore(exam.subscribe, () => exam.getState().unsaved);
4.1 EXH-2830 before MR !3130: 12 keystrokes, then a save -> SaveBadge renders 13 (react 19.3.0, react-dom 19.3.0, jsdom 30.1.1)
4.1 MR !3130: the same steps -> SaveBadge renders 2 · badge reads "Saved"
Each keystroke builds a new snapshot (step 5), so a reader of the whole snapshot renders for each. The flag itself changes only twice, at the first keystroke and at the save; how useSyncExternalStore compares the selected value is the prerequisite's step 8. The same file runs the test list on the reference and on two common mutants: M1's getState returns a copy, and M2's setState replaces the state with the patch.
4.1 reference.mjs 24 of 24 pass (16 core, 8 ladder)
4.1 M1, getState returns a copy 20 of 24 pass · core red: T1 "getState returns the object createStore was given", T5 "getState returns the same snapshot until something changes", T8 "an updater function gets the current snapshot" · ladder red: 1
4.1 M2, setState replaces with the patch 19 of 24 pass · core red: T2 "setState keeps the keys it does not name", T10 "select fires only when its slice changes", T11 "select passes the new slice and the one before it", T12 "keys a setState does not name keep their references" · ladder red: 1
M1 hands out a copy, never its own object, so T1, T5 and T8 fail (step 2). M2 loses every key the patch leaves out, and the slices selected from them.
4.2 Streaming: the queue panel that missed an episode
A video app's player keeps its queue in the reference store, and the queue panel listens through select. One handler pushed into the stored array; a later one built a new array:
const player = createStore({ queue: ['ep1'], volume: 5 });
player.select((s) => s.queue, (q) => log(`queue panel: ${q.length} episodes`));
player.getState().queue.push('ep2');
player.setState({ queue: player.getState().queue });
player.setState({ queue: [...player.getState().queue, 'ep3'] });
4.2 push into the stored queue, then setState({ queue: that array }) -> panel calls 0 · getState().queue holds 2 episodes
4.2 then setState({ queue: [...queue, 'ep3'] }) -> panel calls 1: "queue panel: 3 episodes"
The pushed array is the stored one, so the patch's queue is Object.is-equal to the snapshot's: no change, no new snapshot and no call (step 4), though the data moved underneath (step 6). A new array is a change, and the panel hears it.
Try it
Answer first, then check. Nothing is saved.
Question 1 of 8
A box office redraws its seat map on every notification, and its poller calls seats.setState with each reply from the seat feed. The store is this kata answer:
const seats = createStore({ status: 'open', free: 120, held: new Set() });
seats.subscribe(() => log(`redraw ${seats.getState().free}`));
seats.setState({ status: 'open', free: 120 });
seats.setState({ status: 'closing', free: 119 });
Both replies redraw the map.
Which line, in place of if (Object.is(next, state)) return;, redraws only for the second reply and keeps green the 13 tests of the team's list that need neither select nor an updater function?
export function createStore(initial) { let state = initial; const listeners = new Set(); function setState(patch) { const next = Object.assign({}, state, patch); if (Object.is(next, state)) return; const prev = state; state = next; listeners.forEach((fn) => fn(next, prev)); } return { getState: () => state, setState, subscribe(fn) { listeners.add(fn); return () => listeners.delete(fn); }, };}Question 2 of 8
A video app keeps the viewer's settings in one store, with the playback preferences in a nested prefs object. A candidate's kata answer adds setPrefs(patch) for them, and is complete apart from the line in setPrefs that builds the new snapshot and assigns it to next:
The answer has no select and takes no updater function, so the 19 tests of the team's list that need neither are run, its setPrefs tests included.
Which line passes all 19?
export function createStore(initial) { let state = initial; const listeners = new Set(); function commit(next) { const prev = state; state = next; listeners.forEach((fn) => fn(next, prev)); } function setState(patch) { if (Object.keys(patch).every((k) => Object.is(state[k], patch[k]))) return; commit({ ...state, ...patch }); } function setPrefs(patch) { if (Object.keys(patch).every((k) => Object.is(state.prefs[k], patch[k]))) return; /* the new snapshot */ commit(next); } return { getState: () => state, setState, setPrefs, subscribe(fn) { listeners.add(fn); return () => listeners.delete(fn); }, };}Question 3 of 8
A chat app keeps each room's state in a store. The chat tab's title shows the unread count; its listener reads the count with room.getState(). The store is this kata answer:
const room = createStore({ unread: 0, open: false });
room.subscribe(() => log(`title ${room.getState().unread}`));
room.setState({ unread: 1 });
room.setState({ unread: 2 });
room.setState({ open: true, unread: 0 });
Which lines does the log show?
export function createStore(initial) { let state = initial; const subs = new Set(); return { getState: () => state, setState(patch) { const next = { ...state, ...patch }; subs.forEach((fn) => fn(next, state)); state = next; }, subscribe(fn) { subs.add(fn); return () => subs.delete(fn); }, };}Question 4 of 8
A payments ledger page keeps the balance and the pending count in a store, and its balance panel listens through select. The panel logs each change it is told about as before -> now. The store is this kata answer:
const ledger = createStore({ balance: 100, pending: 0 });
ledger.select((s) => s.balance, (now, before) => log(`${before} -> ${now}`));
ledger.setState({ balance: 120 });
ledger.setState({ pending: 1 });
ledger.setState({ balance: 100 });
What is the second line of the log?
export function createStore(initial) { let state = initial; const subs = new Set(); function setState(patch) { const prev = state; const next = { ...prev, ...patch }; state = next; for (const fn of [...subs]) fn(next, prev); } function select(pick, onChange) { const last = pick(state); const fn = (next) => { const now = pick(next); if (!Object.is(now, last)) onChange(now, last); }; subs.add(fn); return () => subs.delete(fn); } return { getState: () => state, setState, select };}Question 5 of 8
the interviewer says: "Build a minimal Zustand-like store. createStore(initial) returns getState, setState, which takes an object to merge or an updater function, subscribe, and select(selector, onChange). Listeners hear only real changes." You have 20 minutes and a plain editor, and ExamHall's exam page, with its connection status, unsaved-answer flag and draft in one store, is the use case.
Before you type: say your test list out loud.
Name at least eight tests, each with its calls and what it expects.
Question 6 of 8
the interviewer says: "Build a minimal Zustand-like store. createStore(initial) returns getState, setState, which takes an object to merge or an updater function, subscribe, and select(selector, onChange). Listeners hear only real changes." You have 20 minutes and a plain editor, and ExamHall's exam page, with its connection status, unsaved-answer flag and draft in one store, is the use case.
Now write it, in 20 minutes, from a blank file that exports createStore.
Question 7 of 8
A shop's cart store drives two listeners: shipping becomes free at three items, and the banner shows the shipping it is given. Its kata answer:
const cart = createStore({ items: 2, shipping: 'standard' });
cart.subscribe((s) => { if (s.items >= 3) cart.setState({ shipping: 'free' }); });
cart.subscribe((s) => log(`banner ${s.shipping}`));
cart.setState({ items: 3 });
log(`store ${cart.getState().shipping}`);
The banner's last line says standard, yet the store holds free.
Which line, in place of listener(next, prev);, makes the banner's last line say free and keeps green the 18 tests of the team's list that need neither select nor an updater function?
export function createStore(initial) { let state = initial; const listeners = new Set(); function setState(patch) { if (Object.keys(patch).every((k) => Object.is(state[k], patch[k]))) return; const prev = state; const next = { ...state, ...patch }; state = next; for (const listener of [...listeners]) listener(next, prev); } return { getState: () => state, setState, subscribe(listener) { listeners.add(listener); return () => listeners.delete(listener); }, };}Question 8 of 8
A podcast app keeps the episode being played in a store: the speed menu calls episode.setState({ speed: 1.5 }) and works, and the skip button calls episode.setState((s) => ({ position: s.position + 10 })). Its kata answer:
const episode = createStore({ position: 0, speed: 1 });
episode.subscribe((s) => log(`at ${s.position}`));
episode.setState((s) => ({ position: s.position + 10 }));
episode.setState((s) => ({ position: s.position + 10 }));
log(`position ${episode.getState().position}`);
Two taps log only position 0.
Which change makes them log at 10, at 20 and position 20, and keeps green the 21 tests of the team's list that do not call select?
export function createStore(initial) { let snapshot = initial; const subs = new Set(); function setState(input) { if (Object.keys(input).every((key) => Object.is(snapshot[key], input[key]))) return; const prev = snapshot; snapshot = { ...prev, ...(typeof input === 'function' ? input(prev) : input) }; for (const fn of [...subs]) fn(snapshot, prev); } return { getState: () => snapshot, setState, subscribe(fn) { subs.add(fn); return () => subs.delete(fn); }, };}Go deeper
Pro tips
- Say "snapshot", not "state":
getStatemust return the same reference until something changes (test T5), whichuseSyncExternalStorerequires. When: the moment you writegetState, and again when the interviewer asks how React reads the store (step 2; the requirement is the prerequisite's step 9). Verify (RAN,tips.mjs): (the curriculum row's tip, [RAN])tip 1 M1 (getState returns { ...state }) on kata-core.test.mjs: red T1 "getState returns the object createStore was given", T5 "getState returns the same snapshot until something changes", T8 "an updater function gets the current snapshot" - Run your own answer against the list before you call it done:
KATA_IMPL=./my-store.mjs node --test kata-core.test.mjs. When: practising the kata at home, against the same tests the reference passes (step 11). Verify (RAN,tips.mjs):tip 2 KATA_IMPL=./reference.mjs: exit 0 · tests 16 · pass 16 · fail 0 tip 2 KATA_IMPL=<temp>/m1-store.mjs (getState returns a copy): exit 1 · tests 16 · pass 13 · fail 3
Source
Pages change, so their claims are labelled as of 2026-10-04; every behaviour this lesson grades is executed on Node 24.19.0.
- react.dev, useSyncExternalStore (the curriculum row's first source), https://react.dev/reference/react/useSyncExternalStore (
react-usesyncexternalstore.txt). "The subscribe function should subscribe to the store and return a function that unsubscribes." (steps 2, 10). "While the store has not changed, repeated calls to getSnapshot must return the same value." (step 2, tip 1). "The store snapshot returned by getSnapshot must be immutable." (steps 5, 6). As of 2026-10-04. - Zustand, pmndrs/zustand on GitHub (the row's second source), https://github.com/pmndrs/zustand (
zustand-github.txt, the README as served): "State has to be updated immutably and the set function merges state to help it." (steps 5, 6); "Subscribe also exposes the previous value" (select's second argument, step 9). From the same repository's source files, read raw:src/vanilla.ts, https://raw.githubusercontent.com/pmndrs/zustand/main/src/vanilla.ts (zustand-vanilla.txt), whosesetStatesetsstateand then runs "listeners.forEach((listener) => listener(state, previousState))", readingstateat each call (steps 7, 8); its change check, "if (!Object.is(nextState, state)) {", compares only the object it is handed, so Zustand notifies for a new object with equal values, where this kata compares each key (step 4).src/middleware/subscribeWithSelector.ts, https://raw.githubusercontent.com/pmndrs/zustand/main/src/middleware/subscribeWithSelector.ts (zustand-subscribe-with-selector.txt): "let currentSlice = selector(api.getState())" when it subscribes, and "optListener((currentSlice = nextSlice), previousSlice)" when it fires (step 9); itsfireImmediatelyoption, off unless asked for, calls "optListener(currentSlice, currentSlice)". This lesson's reference is its own code, not Zustand's. As of 2026-10-04. - Front End Interview Handbook, JavaScript coding, https://www.frontendinterviewhandbook.com/coding/javascript-utility-function (
feih-javascript-coding.txt, page "Last updated on Aug 13, 2026"): candidates take "around 10-15 minutes for a basic question", against which this lesson sets its own 20 minutes for a store withselect, and it advises "writing test cases for them" (step 11). As of 2026-10-04.
Related lessons
Checked against the tools and docs as of 2026-10-04.