Design a client-side state management system like Redux Toolkit.
Core: a store holding state, dispatch(action) → pure reducer → new state, and a subscribe mechanism. Add selectors with reference-equality memoization, middleware for async/side effects, and a React binding via useSyncExternalStore. RTK adds slices + Immer for ergonomics.
Designing a state library means building the store loop and then the layers that make it usable.
The core: a store
function createStore(reducer, initialState) {
let state = initialState;
const listeners = new Set();
return {
getState: () => state,
dispatch(action) {
state = reducer(state, action); // pure reducer produces next state
listeners.forEach((l) => l()); // notify subscribers
},
subscribe(listener) {
listeners.add(listener);
return () => listeners.delete(listener); // unsubscribe
},
};
}That's the whole Redux core: state + dispatch(action) → pure reducer → new state + notify subscribers. Predictable because every change is a described action through a pure function.
Layer 2: selectors + memoization
Components shouldn't re-render on every dispatch — only when their slice changes. A selector (state) => state.x plus reference-equality comparison: re-render only if the selected value's reference changed. Derived data needs memoized selectors (reselect-style createSelector) so you don't recompute or churn references.
Layer 3: middleware
dispatch is synchronous and pure — but you need async (thunks), logging, persistence. Middleware wraps dispatch in a composable chain:
// middleware: (store) => (next) => (action) => { ... next(action) ... }This is how thunks (dispatch(async () => ...)), logging, and devtools hook in.
Layer 4: the React binding
Bridge the external store into React without tearing under concurrent rendering — that's exactly what useSyncExternalStore is for:
function useSelector(selector) {
return useSyncExternalStore(store.subscribe, () => selector(store.getState()));
}Plus a <Provider> (or a module singleton) and a useDispatch.
Layer 5: ergonomics (the "Toolkit" part)
Raw Redux is boilerplate-heavy. RTK's value-add:
createSlice— generates action creators + reducer from one declaration.- Immer — write "mutating" reducer code; Immer produces the immutable update safely.
configureStore— sane defaults (devtools, thunk).- RTK Query — data-fetching/caching layer on top.
Design decisions to mention
- Immutability — required so reference-equality change detection works.
- Single store vs slices — one tree, namespaced slices.
- Granular subscriptions — selector-level, not "re-render everything."
- Time-travel/devtools — possible because state is immutable and changes are serializable actions.
- Compare to the simpler model (Zustand): a store with
set/getand a hook, no action objects — less ceremony, same subscribe core.
The framing
"The core is small: a store holding state, dispatch(action) running a pure reducer to produce the next state, and a subscribe mechanism to notify listeners — that's what makes it predictable and time-travel-debuggable. On top I'd add selectors with reference-equality memoization so components re-render only for their slice, a middleware chain wrapping dispatch for async and side effects, and a React binding via useSyncExternalStore to subscribe without tearing. The 'Toolkit' layer is ergonomics — createSlice and Immer to kill boilerplate. Immutability is the non-negotiable foundation that makes the change detection and devtools work."
Follow-up questions
- •Why is immutability required for this design?
- •How do selectors prevent unnecessary re-renders?
- •What problem does middleware solve?
- •Why use useSyncExternalStore for the React binding?
Common mistakes
- •Forgetting the subscribe/notify mechanism — the store can't drive re-renders without it.
- •No selector layer, so every component re-renders on every dispatch.
- •Mutating state in the reducer, breaking change detection.
- •Hand-rolling the React binding instead of useSyncExternalStore (tearing under concurrency).
Performance considerations
- •Granular selector subscriptions and memoized derived selectors are the perf core — they bound re-renders to actual slice changes. Notifying all listeners on every dispatch is O(subscribers); selectors filter that down.
Edge cases
- •Selector returning a new object/array each call — defeats memoization.
- •Middleware ordering in the chain.
- •Concurrent rendering tearing without useSyncExternalStore.
- •Non-serializable values breaking devtools/time-travel.
Real-world examples
- •Redux/RTK, Zustand, Jotai — all variations on store + subscribe + selectors.
- •react-redux using useSyncExternalStore internally.