Implement It from Scratch: 5 Front-End State Management Primitives Every Developer Should Know
In senior and mid-level front-end engineering interviews, live coding rounds frequently test your ability to implement state management primitives from first principles. Rather than simply installing Redux, Zustand, or Immer, interviewers evaluate whether you genuinely understand reactivity, pub-sub architectures, memoization caching, and immutability guarantees.
Interviewers evaluate:
- Observable State & Subscriptions: Constructing centralized stores with
getState,setState, and listener cleanup functions. - Selector Memoization: Caching derived state calculations based on shallow reference equality (the Reselect pattern).
- Fine-Grained Reactivity (Signals): Implementing automatic dependency tracking with subscriber stacks (the SolidJS / Preact Signals pattern).
- Temporal State (Undo / Redo): Managing past, present, and future history stacks with capacity limits and branching invalidation.
- Proxy-Based Immutability: Intercepting object mutations via JavaScript
Proxyto produce immutable state updates without manual object spreading (the Immer pattern).
This guide walks through 5 high-frequency state management live-coding problems for developers with 0–5 years of experience.
Question Index
| # | Primitive | Core Pattern | Difficulty |
|---|---|---|---|
| 1 | createStore | Centralized observable store, pub-sub, partial state merges | Junior-Mid |
| 2 | createSelector | Memoized selector, input caching, reference equality | Mid |
| 3 | createSignal & createEffect | Fine-grained reactivity, automatic dependency tracking | Mid-Senior |
| 4 | createHistoryStore | Undo/redo temporal state, history stacks, capacity limits | Mid |
| 5 | produce | Proxy-based immutable draft updates (Immer pattern) | Senior |
1. Implement a Lightweight Observable Store (createStore)
Requirements
Implement a centralized state store supporting subscriptions and functional updates (Zustand / Redux pattern):
- Accept an
initialStateobject or primitive. - Provide
getState()returning the current state snapshot. - Provide
setState(partialOrUpdater)allowing either a partial state object (shallow merged) or an updater function(prevState) => nextState. - Provide
subscribe(listener)returning anunsubscribecleanup function. - Notify all active listeners synchronously whenever state changes.
- Bail out of notifying subscribers if the new state is shallowly identical (
Object.is(prevState, nextState)).
Clarifying Questions to Ask the Interviewer
- "Should
setStateshallow-merge objects by default, like Zustand, or replace state entirely, like Redux?" (Usually: shallow-merge if both are objects, otherwise replace). - "Can listeners safely unsubscribe while a notification cycle is in progress?" (Yes, avoid mutating the active listener set during iteration).
Approach
- Store
currentStatein closure. - Maintain a
Set<() => void>of listeners forO(1)addition and deletion. - In
setState, compute next state. If next state equals current state viaObject.is, bail out early. - Update
currentStateand iterate over a snapshot copy of the listenersnew Set(listeners)to prevent mutation during dispatch. - Return a parameterless unsubscribe function from
subscribe.
Implementation
type StateUpdater<T> = Partial<T> | ((prevState: T) => Partial<T> | T);
type Listener = () => void;
export interface Store<T> {
getState: () => T;
setState: (updater: StateUpdater<T>) => void;
subscribe: (listener: Listener) => () => void;
}
export function createStore<T extends Record<string, any>>(initialState: T): Store<T> {
let state = initialState;
const listeners = new Set<Listener>();
const getState = (): T => state;
const setState = (updater: StateUpdater<T>): void => {
const nextState =
typeof updater === 'function'
? (updater as (prev: T) => Partial<T> | T)(state)
: updater;
// Shallow merge if state and update are objects, otherwise replace
const computedNext =
typeof state === 'object' && state !== null && typeof nextState === 'object' && nextState !== null
? { ...state, ...nextState }
: (nextState as T);
if (Object.is(state, computedNext)) {
return; // Bail out if unchanged
}
state = computedNext;
// Snapshot iteration prevents listener set mutation bugs
const snapshot = Array.from(listeners);
for (const listener of snapshot) {
listener();
}
};
const subscribe = (listener: Listener): (() => void) => {
listeners.add(listener);
return () => {
listeners.delete(listener);
};
};
return { getState, setState, subscribe };
}
Edge Cases Handled
- Unsubscribing during dispatch: If listener A unregisters listener B during notification, snapshot iteration prevents index misalignment errors.
- Identical State Bailout: Prevents unnecessary UI re-renders if state hasn't changed.
- Functional Updaters: Properly evaluates
(prev) => ({ count: prev.count + 1 }).
Complexity
- Time Complexity:
getState:O(1).subscribe/unsubscribe:O(1).setState:O(L)whereLis the number of active listeners. - Space Complexity:
O(L)whereLis subscriber count.
Usage Example
const store = createStore({ count: 0, user: 'Alex' });
const unsubscribe = store.subscribe(() => {
console.log('Store updated:', store.getState());
});
store.setState({ count: 1 }); // Logs: Store updated: { count: 1, user: 'Alex' }
store.setState((prev) => ({ count: prev.count + 1 })); // Logs: { count: 2, user: 'Alex' }
unsubscribe();
store.setState({ count: 3 }); // No listener notification
Likely Follow-ups
- "How do you connect this store to React without tearing in React 18?" (Use React's native
useSyncExternalStore(store.subscribe, store.getState)). - "How would you add middleware support?" (Wrap
setStatein a middleware pipeline function).
2. Implement a Memoized Selector (createSelector / Reselect)
Requirements
Implement a memoized selector function that computes derived state (Reselect pattern):
- Accept an array of input selectors and a result combiner function.
- Cache the output of the combiner function.
- When called, execute all input selectors with the provided arguments.
- If every input selector returns the same reference (
===) as the previous run, return the cached result without executing the combiner. - If any input selector returns a new reference, re-run the combiner and update the cache.
Clarifying Questions to Ask the Interviewer
- "Should equality be shallow equality or strict identity (
===)?" (Reselect defaults to strict reference identity===). - "What should the cache size be?" (Default to cache size 1, matching standard Reselect behavior).
Approach
- Maintain
lastInputs: any[] | nullandlastResult: anyin closure. - In the returned selector, compute
currentInputs = inputSelectors.map(fn => fn(...args)). - If
lastInputsexists and has identical length, check if every elementcurrentInputs[i] === lastInputs[i]. - If all match, return
lastResult. - Otherwise, compute
lastResult = combiner(...currentInputs), savelastInputs = currentInputs, and returnlastResult.
Implementation
type Selector<State, Result> = (state: State) => Result;
export function createSelector<State, Results extends any[], Output>(
inputSelectors: [...{ [K in keyof Results]: Selector<State, Results[K]> }],
combiner: (...inputs: Results) => Output
): Selector<State, Output> {
let lastInputs: Results | null = null;
let lastResult: Output | null = null;
return function memoizedSelector(state: State): Output {
const currentInputs = inputSelectors.map((sel) => sel(state)) as Results;
if (
lastInputs !== null &&
lastInputs.length === currentInputs.length &&
currentInputs.every((val, idx) => Object.is(val, lastInputs![idx]))
) {
return lastResult as Output;
}
lastInputs = currentInputs;
lastResult = combiner(...currentInputs);
return lastResult;
};
}
Edge Cases Handled
NaNComparisons: UsesObject.issoNaN === NaNevaluates to true, avoiding unnecessary recalculations.- Multiple Arguments: Correctly compares all input selector results positionally.
- First Invocation: Properly handles
lastInputs === nullon initial run.
Complexity
- Time Complexity: Cache hit:
O(K)whereKis the number of input selectors. Cache miss:O(K + CombinerExecutionTime). - Space Complexity:
O(K)to storelastInputsandlastResult.
Usage Example
interface AppState {
items: Array<{ id: number; price: number; taxable: boolean }>;
taxRate: number;
}
const selectItems = (state: AppState) => state.items;
const selectTaxRate = (state: AppState) => state.taxRate;
const selectTotalTax = createSelector(
[selectItems, selectTaxRate],
(items, taxRate) => {
console.log('Computing tax calculation...');
return items
.filter((i) => i.taxable)
.reduce((acc, curr) => acc + curr.price * taxRate, 0);
}
);
const state = { items: [{ id: 1, price: 100, taxable: true }], taxRate: 0.1 };
console.log(selectTotalTax(state)); // Logs "Computing tax calculation...", returns 10
console.log(selectTotalTax(state)); // Cached! Returns 10 without recalculating
Likely Follow-ups
- "What happens if an input selector returns a new array reference every time (e.g.
state.items.filter(...))?" (Memoization breaks because===checks fail. Input selectors should only extract raw data; transformations belong in the combiner). - "How would you customize the equality check for input arguments?" (Accept an optional
equalityCheckfunction, e.g. shallow array equality).
3. Implement Fine-Grained Reactive Signals (createSignal & createEffect)
Requirements
Implement a fine-grained reactivity system (SolidJS / Preact Signals pattern):
createSignal(initialValue)returns a getter[read, write].createEffect(callback)runs the callback immediately and automatically subscribes to any signals read during execution.- When any dependency signal changes via
write(newValue), all dependent effects automatically re-execute. - Subscriptions must be dynamic: if an effect's branching logic changes which signals it reads, subscriptions update accordingly.
- Prevent infinite subscriber loops.
Clarifying Questions to Ask the Interviewer
- "Do we need to support nested effects?" (Maintain a context execution stack:
contextStack = []). - "Should reading a signal outside of an effect throw an error?" (No, simply return the current value without registering a subscriber).
Approach
- Maintain an active context stack:
context: (() => void)[] = []. - In
createEffect(fn), define an execution runnerexecutethat pushes itself onto the stack, runsfn(), and pops itself off in afinallyblock. - In
createSignal(value):- Getter: Check if there is an active effect at
context[context.length - 1]. If so, add it to an internal subscriber setsubscribers.add(activeEffect). Returnvalue. - Setter: If new value equals old value (
Object.is), bail out. Otherwise update value, copy subscribersnew Set(subscribers), and re-run them.
- Getter: Check if there is an active effect at
Implementation
type RunningEffect = () => void;
const contextStack: RunningEffect[] = [];
export function createEffect(fn: () => void): void {
const execute: RunningEffect = () => {
contextStack.push(execute);
try {
fn();
} finally {
contextStack.pop();
}
};
execute(); // Run immediately to establish initial subscriptions
}
export function createSignal<T>(initialValue: T): [() => T, (nextValue: T | ((prev: T) => T)) => void] {
let value = initialValue;
const subscribers = new Set<RunningEffect>();
const read = (): T => {
const currentObserver = contextStack[contextStack.length - 1];
if (currentObserver) {
subscribers.add(currentObserver);
}
return value;
};
const write = (nextValue: T | ((prev: T) => T)): void => {
const computedNext =
typeof nextValue === 'function'
? (nextValue as (prev: T) => T)(value)
: nextValue;
if (Object.is(value, computedNext)) {
return;
}
value = computedNext;
// Snapshot subscribers before invoking to prevent infinite loops
const snapshot = Array.from(subscribers);
for (const effect of snapshot) {
effect();
}
};
return [read, write];
}
Edge Cases Handled
- Nested Effects: Stack architecture
contextStackcorrectly attributes dependencies to the currently executing inner effect. - Reading outside effects: Reading a signal in standard code works as a normal getter without error.
- Unchanged values: Bails out of effect execution when set to an identical value via
Object.is.
Complexity
- Time Complexity: Read:
O(1). Write:O(S)whereSis the number of subscribed effects. - Space Complexity:
O(S)whereSis subscriber relationships.
Usage Example
const [count, setCount] = createSignal(0);
const [name, setName] = createSignal('Alice');
createEffect(() => {
console.log(`Log: ${name()} has count ${count()}`);
});
// Logs immediately: "Log: Alice has count 0"
setCount(1); // Logs: "Log: Alice has count 1"
setName('Bob'); // Logs: "Log: Bob has count 1"
Likely Follow-ups
- "How do you handle cleanup when an effect re-runs or unmounts?" (Track bidirectional dependencies so effects unregister old subscribers before re-executing).
- "How do you derive computed signals (
createMemo)?" (Combine a signal and an effect that writes to an internal signal).
4. Implement an Undo/Redo State History Manager (createHistoryStore)
Requirements
Implement a state store that maintains temporal history for undo and redo operations:
- Initialize with an
initialStateand amaxHistorylimit (e.g., 20 steps). set(nextState): Push current state topast, setpresent = nextState, and clearfuture(branching history invalidation).undo(): Move current state tofuture, pop top ofpastintopresent. Ifpastis empty, no-op.redo(): Move current state topast, pop top offutureintopresent. Iffutureis empty, no-op.- Provide
canUndoandcanRedoboolean flags. - Enforce
maxHistorysopastnever exceeds the maximum capacity limit.
Clarifying Questions to Ask the Interviewer
- "Should multiple identical state updates create undo steps?" (No, bail out if
Object.is(present, val)). - "What happens to the redo stack when a user types a new action after undoing?" (Standard behavior: a new
setclears thefuturestack).
Approach
- Maintain data structure:
past: T[],present: T,future: T[]. - When
set(val): IfObject.is(present, val)is true, return. Pushpresenttopast. Ifpast.length > maxHistory, shift oldest state out. Setpresent = val. Resetfuture = []. - In
undo(): Checkpast.length > 0. Pushpresentontofuture. Pop last item frompastand assign topresent. - In
redo(): Checkfuture.length > 0. Pushpresentontopast. Pop first item fromfutureand assign topresent.
Implementation
export interface HistoryStore<T> {
getState: () => T;
set: (nextState: T | ((prev: T) => T)) => void;
undo: () => boolean;
redo: () => boolean;
canUndo: () => boolean;
canRedo: () => boolean;
subscribe: (listener: () => void) => () => void;
}
export function createHistoryStore<T>(initialState: T, maxHistory = 20): HistoryStore<T> {
let past: T[] = [];
let present: T = initialState;
let future: T[] = [];
const listeners = new Set<() => void>();
const notify = () => {
listeners.forEach((fn) => fn());
};
return {
getState: () => present,
canUndo: () => past.length > 0,
canRedo: () => future.length > 0,
set: (updater) => {
const nextState =
typeof updater === 'function' ? (updater as (prev: T) => T)(present) : updater;
if (Object.is(present, nextState)) return;
past.push(present);
if (past.length > maxHistory) {
past.shift(); // Evict oldest history entry
}
present = nextState;
future = []; // Clear redo stack on new action
notify();
},
undo: () => {
if (past.length === 0) return false;
const previous = past.pop()!;
future.unshift(present);
present = previous;
notify();
return true;
},
redo: () => {
if (future.length === 0) return false;
const next = future.shift()!;
past.push(present);
present = next;
notify();
return true;
},
subscribe: (listener) => {
listeners.add(listener);
return () => listeners.delete(listener);
},
};
}
Edge Cases Handled
- Boundary Calls: Calling
undo()with emptypastorredo()with emptyfuturesafely returnsfalsewithout modifying state. - Branching Action Invalidation: Pushing a new action after an undo clears
future, avoiding temporal state corruption. - Memory Leak Protection:
past.shift()enforcesmaxHistorybounds.
Complexity
- Time Complexity:
getState,canUndo,canRedo:O(1).set,undo,redo:O(1)amortized. - Space Complexity:
O(maxHistory)storing snapshot references.
Usage Example
const editor = createHistoryStore('Version 1', 5);
editor.set('Version 2');
editor.set('Version 3');
console.log(editor.getState()); // 'Version 3'
editor.undo();
console.log(editor.getState()); // 'Version 2'
editor.redo();
console.log(editor.getState()); // 'Version 3'
editor.undo();
editor.set('Version 2.1'); // Branching change clears redo
console.log(editor.canRedo()); // false
Likely Follow-ups
- "How would you implement this for large documents where full snapshots consume too much RAM?" (Store JSON Patches / diffs like RFC 6902 instead of full object copies).
- "How do you group rapid continuous updates (like typing text) into a single undo step?" (Add debounced batching or a transaction flag).
5. Implement an Immutable State Updater (produce / Immer Pattern)
Requirements
Implement an immutable update utility that allows writing mutating logic against a draft proxy (Immer pattern):
produce(baseState, recipe): Takes abaseStateobject and arecipecallback.- Inside
recipe(draft), developers can use standard mutating JavaScript operations (draft.user.age = 26,draft.tags.push('new')). - Return a brand-new immutable copy incorporating the mutations.
- Structural sharing: untouched branches in the object graph must preserve their original object references (
===). - If the recipe performs zero mutations, return
baseStateunmodified.
Clarifying Questions to Ask the Interviewer
- "Do we need to support recursive nested objects?" (Yes, lazily create proxies when accessing nested child objects).
- "Should arrays be supported?" (Yes, handle array mutations like
pushor indexing).
Approach
- Create a recursive shallow copy mechanism triggered on first write (Copy-on-Write pattern).
- Use a JavaScript
Proxy:get: Return a proxied draft of child objects if they haven't been copied yet.set: On first mutation of a node, create a shallow copy{ ...target }in amodifiedmap, apply the write, and mark ancestors as modified.
- After the recipe finishes, recursively assemble the final tree: return modified copies for mutated nodes, and original references for untouched nodes.
Implementation
export function produce<T extends Record<string, any>>(baseState: T, recipe: (draft: T) => void): T {
let modified = false;
const copies = new Map<any, any>();
const proxies = new Map<any, any>();
function createProxy<O extends Record<string, any>>(target: O): O {
if (typeof target !== 'object' || target === null) {
return target;
}
if (proxies.has(target)) {
return proxies.get(target);
}
const proxy = new Proxy(target, {
get(obj, prop) {
if (prop === '__isProxy') return true;
const currentTarget = copies.get(obj) || obj;
const val = currentTarget[prop];
if (typeof val === 'object' && val !== null) {
return createProxy(val);
}
return val;
},
set(obj, prop, value) {
let copy = copies.get(obj);
if (!copy) {
copy = Array.isArray(obj) ? [...obj] : { ...obj };
copies.set(obj, copy);
modified = true;
}
copy[prop] = value;
return true;
},
});
proxies.set(target, proxy);
return proxy;
}
const rootProxy = createProxy(baseState);
recipe(rootProxy);
if (!modified) {
return baseState;
}
function finalize<O>(current: O): O {
if (typeof current !== 'object' || current === null) {
return current;
}
const copy = copies.get(current);
if (!copy) {
return current; // Structural sharing: unchanged branch
}
if (Array.isArray(copy)) {
return copy.map(finalize) as any;
}
const result: any = {};
for (const key of Object.keys(copy)) {
result[key] = finalize(copy[key]);
}
return result;
}
return finalize(baseState);
}
Edge Cases Handled
- Structural Sharing: Unmodified siblings retain exact memory references (
===), preventing downstream React re-renders. - Zero Mutations: If the recipe doesn't write anything, returns the original
baseStatewithout cloning. - Array and Object Support: Supports both arrays (
push, index assignments) and nested dictionaries.
Complexity
- Time Complexity:
O(M)whereMis the number of visited and modified nodes (proportional only to the changed subtree, not the entire state tree). - Space Complexity:
O(M)storing shallow copies of modified branches.
Usage Example
const state = {
user: { name: 'Alex', details: { city: 'SF', age: 30 } },
posts: [{ id: 1, title: 'React 19' }],
};
const nextState = produce(state, (draft) => {
draft.user.details.age = 31;
});
console.log(nextState.user.details.age); // 31
console.log(state.user.details.age); // 30 (immutable original)
console.log(nextState.posts === state.posts); // true (structural sharing preserved!)
Likely Follow-ups
- "How does Immer handle
Object.freeze?" (In development, Immer freezes the returned state to prevent accidental out-of-recipe mutations). - "Why use a Proxy over
structuredClone?" (structuredCloneduplicates the entire object graphO(N)and destroys structural sharing; Proxy copy-on-write only clones modified branchesO(M)).
Preparation Checklist
To excel in state management and reactivity live-coding interviews, review this checklist:
- Master the Store pattern: Practice implementing
createStorewithgetState,setState, and listener cleanup sets. - Understand Memoization Mechanics: Implement
createSelectorwith input caching andObject.ischecks. - Track Signal Dependencies: Practice the stack-based tracking pattern (
contextStack.push(effect)) for automatic reactive dependency discovery. - Manage Temporal History: Implement undo/redo with past, present, and future stacks and boundary capacity eviction.
- Understand Copy-on-Write Proxies: Explain how JavaScript
Proxyenables structural sharing and mutable draft syntax. - Connect External Stores to React: Understand how
useSyncExternalStoresubscribes to external stores without tearing.
Conclusion
State management interview questions evaluate architectural judgment and mastery of JavaScript runtime mechanics. By understanding how observable stores dispatch updates, how selectors memoize computations, how signals automatically track dependencies, and how proxies preserve structural sharing, you demonstrate senior-level software design maturity.