InterviewsVector
advancedRare7 min read · Updated Jul 18, 2026

Build a Mini Redux Store with an Immer-style produce()

Implement createStore (dispatch, subscribe, getState) and a Proxy-based copy-on-write produce in JavaScript — structural sharing, reference equality, and why deep copy is not Immer.


The problem

Two-part exercise: (1) implement createStore(reducer)getState, dispatch, subscribe; (2) implement produce(state, recipe) — let reducers write mutation-style code while actually producing immutable updates.

This is the machine-coding question that fuses three catalog threads: closures for the store (the module pattern), pub/sub for subscriptions (event emitter), and Proxy traps for the drafts (negative indexing's big sibling). It's also where a fake answer is easiest to spot — so first, the trap.

The trap: deep copy is not Immer

The tutorial-circuit version of produce is JSON.parse(JSON.stringify(base)), mutate, return. Three strikes:

  1. It inherits every JSON limitation — dates become strings, undefined and functions vanish, Map/Set become {}.
  2. It copies the entire tree on every action — O(state size) per dispatch. Immer's whole reason to exist is structural sharing: copy only the path you touched, share every untouched subtree by reference.
  3. It breaks reference equality — a no-op action returns a brand-new tree, so every state.x !== prevState.x, so every selector re-fires and every memoized component re-renders. Reference stability isn't a nicety; it's the contract React/Redux performance is built on (memoizeLast is the machinery that consumes it).

Bonus strike: seeding the store via produce(undefined, ...) makes JSON.parse(undefined) throw — the deep-copy version often crashes before the first action. If you remember one sentence: Immer is copy-on-write, not copy-everything.

Part 1: the store

function createStore(reducer, preloadedState) {
  let state = preloadedState;
  let listeners = new Set();
 
  const getState = () => state;
 
  function dispatch(action) {
    state = reducer(state, action); // reducer RETURNS the next state
    for (const listener of [...listeners]) listener(); // snapshot — same
    return action;                  //   re-entrancy discipline as an emitter
  }
 
  function subscribe(listener) {
    listeners.add(listener);
    return () => listeners.delete(listener); // unsubscribe closure
  }
 
  dispatch({ type: "@@INIT" }); // reducer's default-param supplies initial state
  return { getState, dispatch, subscribe };
}

Small surface, three narratable choices: the reducer returns state (the contract that makes time-travel/devtools possible — state transitions are pure function applications); listeners iterate over a snapshot (a listener that unsubscribes mid-notify must not skip others — the same bug class as emit); subscribe returns the unsubscriber (the leak-prevention idiom useEffect cleanup expects).

Part 2: produce — copy-on-write with Proxies

The idea: hand the recipe a draft (a Proxy). Reads pass through to the real state. The first write to any object shallow-copies that object — and tells its parent to copy too, so the modification bubbles up a copied path while untouched branches stay shared:

function produce(base, recipe) {
  let result = base; // unchanged until something actually writes
 
  function createDraft(source, onFirstWrite) {
    let copy = null;                      // created lazily, on first write
    const current = () => copy ?? source; // reads prefer the copy once it exists
    const childDrafts = new Map();
 
    const ensureCopy = () => {
      if (copy === null) {
        copy = Array.isArray(source) ? [...source] : { ...source };
        onFirstWrite(copy); // bubble: parent copies itself and points at us
      }
      return copy;
    };
 
    return new Proxy(source, {
      get(_, prop) {
        const value = current()[prop];
        // Wrap nested objects in their own drafts (lazily, once)
        if (typeof value === "object" && value !== null) {
          if (!childDrafts.has(prop)) {
            childDrafts.set(prop, createDraft(value, (childCopy) => {
              ensureCopy()[prop] = childCopy;
            }));
          }
          return childDrafts.get(prop);
        }
        return value; // primitives and methods (push etc.) pass through
      },
      set(_, prop, value) {
        ensureCopy()[prop] = value; // never touches the original
        return true;
      },
      deleteProperty(_, prop) {
        delete ensureCopy()[prop];
        return true;
      },
      has: (_, prop) => prop in current(),
      ownKeys: () => Reflect.ownKeys(current()),
      getOwnPropertyDescriptor(_, prop) {
        const desc = Object.getOwnPropertyDescriptor(current(), prop);
        if (desc) desc.configurable = true;
        return desc;
      },
    });
  }
 
  const draft = createDraft(base, (rootCopy) => { result = rootCopy; });
  const returned = recipe(draft);
  return returned !== undefined ? returned : result;
}

Two properties fall out that the deep-copy fake cannot produce:

const state = {
  todos: [{ text: "a", done: false }, { text: "b", done: false }],
  settings: { theme: "dark" },
};
 
const next = produce(state, (draft) => {
  draft.todos[0].done = true;
});
 
next !== state;                       // true  — changed path copied
next.todos[0] !== state.todos[0];     // true
next.todos[1] === state.todos[1];     // true  — SHARED: structural sharing ✓
next.settings === state.settings;     // true  — untouched branch shared ✓
 
const same = produce(state, () => {}); // recipe touched nothing
same === state;                        // true  — no-op returns the SAME reference ✓

That last check — no writes ⇒ same reference — is what makes dispatch of an unmatched action free for subscribers doing prev === next checks. Array methods work through the traps too: draft.todos.push(x) calls Array.prototype.push with the proxy as this, so its internal reads and writes route through get/set — the copy-on-write happens mid-push without push knowing.

Putting it together (Redux Toolkit's actual shape)

const initialState = { todos: [] };
 
const todosReducer = (state = initialState, action) =>
  produce(state, (draft) => {
    switch (action.type) {
      case "ADD_TODO":
        draft.todos.push({ text: action.text, done: false });
        break;
      case "TOGGLE_TODO":
        draft.todos[action.index].done = !draft.todos[action.index].done;
        break;
      // no default case needed: an untouched draft returns state AS-IS
    }
  });
 
const store = createStore(todosReducer);
store.subscribe(() => console.log(store.getState()));
store.dispatch({ type: "ADD_TODO", text: "ship it" });

This is precisely why Redux Toolkit wraps every reducer in produce: mutation-style ergonomics, spread-free deep updates, immutability guaranteed by construction. Being able to say "RTK's createSlice is produce around your case reducers" places the exercise in the real world.

Edge cases interviewers probe

  • Recipe both mutates and returns — real Immer throws (ambiguous intent); our version silently prefers the return. Either policy is defensible stated; not noticing the ambiguity isn't.
  • Escaping drafts — storing draft.todos into an outer variable and using it after produce returns is touching a live Proxy over dead state; real Immer revokes drafts on exit (Proxy.revocable) to make this throw. Knowing revocation exists is the hardened answer.
  • Frozen results — Immer freezes produced state in development so accidental later mutation throws loudly; one Object.freeze sentence.
  • Map/Set/class instances in state — the toy handles plain objects/arrays; real Immer has explicit Map/Set support and an opt-in for classes. Also the honest scope note for this exercise.
  • Reading Object.keys(draft) after adds — works here because ownKeys consults current(); implementations that only trap get/set silently report stale keys — a good "what did you not trap?" probe (minimal-traps discussion).

Common mistakes

  • JSON round-trip produce (all three strikes, plus the undefined crash).
  • Reducers that mutate state directly "because produce will fix it" — outside a draft that's just mutation; the discipline is only drafts absorb writes.
  • Notifying listeners without a snapshot.
  • Forgetting the no-op ⇒ same-reference property, then wondering why every component re-renders (the symptom form of strike 3 — interviewers often present it as a debugging scenario).
  • Copying on read — copy-on-write is the algorithm; a draft that copies everything it looks at is deep clone with extra steps.

Follow-up questions

  • "Add middleware (applyMiddleware)." — wrap dispatch in a compose of store => next => action functions — the curried-middleware signature is currying in the wild.
  • "Add selector subscriptions (subscribeWithSelector)." — per-listener: run selector, compare with previous via === (structural sharing makes this sound!), notify on change — memoizeLast per subscriber.
  • "How does Immer generate patches?" — record { op, path, value } during writes — enables undo/redo and syncing over the wire; connects to the virtual-DOM patch-list idea.
  • "Immer vs spread syntax?" — spreads are fine at depth 1–2; the pyramid { ...s, a: { ...s.a, b: { ...s.a.b, c } } } at depth 3+ is where produce pays. Cost: Proxy overhead per access — measurable in huge hot loops, irrelevant in reducers. A calibrated answer, not an advocacy answer.
  • "Why does Redux require immutability at all?" — change detection by reference (=== in connect/useSelector), time-travel debugging, and predictable replay — the three-legged answer that shows you know why the constraint exists rather than treating it as ritual.

  • classnames()

    beginner

    Rebuild the classnames utility: strings, arrays, objects, and nested combinations.

  • Diff two virtual trees and apply minimal DOM mutations — the reconciliation step that makes the virtual DOM worth having.

  • Turn real DOM into a plain-object tree — the first half of understanding how React represents UI.