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.
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
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
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:
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 logWhy 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
- Setting state during notify — listeners should see a consistent snapshot. Snapshot
Array.from(listeners)before iterating, or queue notifications. - Unsubscribe during notify —
Set.deleteduring iteration is safe per spec, but snapshotting is safer. - Equality for objects —
Object.iswon't catch{a:1} !== {a:1}; exposeisEqualso consumers passshallow/deepEqualwhen they return derived objects. - 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.