Back to System Design
System Design
hard
mid

How would you 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.

6 min read·~15 min to think through

Designing a state library means building the store loop and then the layers that make it usable.

The core: a store

js
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:

js
// 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:

js
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/get and 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.

Senior engineer discussion

Seniors build the store loop, then layer selectors/memoization, middleware, and a tear-free React binding, explain immutability as foundational, and compare the Redux model to lighter alternatives like Zustand.

Related questions