Back to JavaScript
JavaScript
easy
mid

How would you implement a simplified pub sub or selector dispatch state subscription system?

A pub/sub keeps a Set of subscribers and a state; `subscribe(fn)` returns an unsubscribe; `setState(updater)` produces next state and notifies. A selector layer wraps it: each subscriber registers a selector + equalityFn (default `Object.is`) and is only re-notified when its selected slice changes — the kernel of Redux/Zustand.

4 min read·~20 min to think through

Two layers: a tiny store (pub/sub on a single state object) and selector subscriptions on top so consumers only re-run when their slice changes.

Store kernel

js
function createStore(initial) {
  let state = initial;
  const listeners = new Set();

  return {
    getState: () => state,
    setState(updater) {
      const next = typeof updater === "function" ? updater(state) : updater;
      if (Object.is(next, state)) return;          // bail if reference equal
      state = next;
      listeners.forEach((l) => l());                // notify
    },
    subscribe(listener) {
      listeners.add(listener);
      return () => listeners.delete(listener);     // unsubscribe
    },
  };
}

Selector layer

js
function subscribeSelector(store, selector, callback, isEqual = Object.is) {
  let prev = selector(store.getState());
  return store.subscribe(() => {
    const next = selector(store.getState());
    if (!isEqual(prev, next)) {
      const old = prev;
      prev = next;
      callback(next, old);
    }
  });
}

Usage:

js
const store = createStore({ count: 0, user: null });

const unsub = subscribeSelector(
  store,
  (s) => s.count,
  (count) => console.log("count is", count),
);

store.setState((s) => ({ ...s, count: s.count + 1 }));   // logs
store.setState((s) => ({ ...s, user: { id: 1 } }));      // does NOT log

Why selectors matter

A naive pub/sub re-runs every subscriber on every change. With 50 components on a single store, every keystroke retriggers all of them. Selectors gate by referential (or custom) equality on the slice, so a component only re-renders when its data actually changed — the Redux useSelector / Zustand useStore(selector) pattern.

Edge cases worth handling

  1. Setting state during notify — listeners should see a consistent snapshot. Snapshot Array.from(listeners) before iterating, or queue notifications.
  2. Unsubscribe during notifySet.delete during iteration is safe per spec, but snapshotting is safer.
  3. Equality for objectsObject.is won't catch {a:1} !== {a:1}; expose isEqual so consumers pass shallow / deepEqual when they return derived objects.
  4. Memoized derivations — for expensive selectors, memoize on input identity (reselect-style).

Interview framing

"A Set of listeners + a state, with setState bailing on identity equality, gives you the kernel. Subscribers wrap a selector so they only fire when their slice changes — that's exactly how Redux's useSelector and Zustand work. Pass a custom isEqual for object slices. Watch out for subscribe/unsubscribe during notify, and for state-during-notify reentrancy — snapshot the listener set before iterating."

Follow-up questions

  • How would you add middleware?
  • How to integrate with React useSyncExternalStore?
  • How would shallow equality help?

Common mistakes

  • Notifying without checking reference equality.
  • Mutating state instead of producing a new object.
  • Iterating the listener Set while subscribers add/remove others.

Performance considerations

  • Selector layer keeps render fan-out tight; without it every update wakes every subscriber. Avoid expensive selectors — memoize.

Edge cases

  • Reentrant setState in a listener.
  • Unsubscribe-during-notify.
  • Selectors that return new object references each call (always 'changed').

Real-world examples

  • Redux store, Zustand, Jotai, Recoil, EventEmitter pattern, React's useSyncExternalStore.

Senior engineer discussion

Seniors discuss reentrancy, snapshot semantics for concurrent React (`useSyncExternalStore` requires a consistent getSnapshot), and selector equality strategies. They also know batching: React 18 batches by default, but external stores need their own batching if updates fan out.

Related questions