Redux, Zustand, and every "global state" hook share a tiny core: a single object holding state, a way to update it, and a list of listeners that fire whenever it changes. Strip away the middleware, selectors, and React glue and you're left with the observable store — maybe forty lines. Building it by hand is the fastest way to understand what those libraries actually do.
Implement observableStore(initialState). It returns an object with getState(), setState(update), and subscribe(listener), where setState merges an update and notifies every subscriber.
function observableStore(initialState) {
return {
getState, // () => state
setState, // (partial | (prev) => partial) => void
subscribe, // (listener) => unsubscribe
};
}
const store = observableStore({ count: 0, name: 'a' });
const off = store.subscribe((next, prev) => console.log(prev, '->', next));
store.setState({ count: 1 }); // logs {count:0,name:'a'} -> {count:1,name:'a'}
store.setState((s) => ({ count: s.count + 1 })); // updater form -> count 2
store.getState(); // { count: 2, name: 'a' }
off(); // stop listening
setState({ count: 1 }) keeps the other keys. The updater form (prev) => partial computes the patch from the current state.prevState stays a valid snapshot.listener(nextState, prevState).We'll keep state and a set of listeners in a closure; setState computes a new state and calls each listener, and subscribe hands back a way to remove one.
A store is a value plus a mailing list. getState reads the value. setState changes it and tells everyone. subscribe gets you on the list and gives you an unsubscribe. The whole design is a closure over two variables — the current state and a Set of listeners — with three small methods reading and writing them.
Think of one shared cell and a broadcast. When setState runs, it builds the next value by merging your patch onto the current state, swaps it in, then walks the listener list calling each with the new and old values. Reads (getState) never touch the listeners; writes always do.
The obvious version mutates state in place and loops the live listener set:
function observableStoreNaive(initial) {
let state = initial;
const listeners = new Set();
return {
getState: () => state,
setState(patch) {
Object.assign(state, patch); // mutates the same object
listeners.forEach((fn) => fn(state)); // no prev; iterates live set
},
subscribe(fn) { listeners.add(fn); return () => listeners.delete(fn); },
};
}
Two bugs hide here. Mutating state means a listener that stored the "previous" value now sees it changed underfoot — no reliable prevState. And iterating listeners directly while a listener might unsubscribe another causes a listener to be skipped mid-round (mutation during iteration).
function observableStore(initialState) {
let state = initialState;
const listeners = new Set();
function getState() {
return state;
}
function setState(update) {
const prev = state;
const patch = typeof update === 'function' ? update(prev) : update;
state = { ...prev, ...patch }; // new object; prev stays valid
// Snapshot first: subscribing/unsubscribing mid-round won't disturb it.
for (const listener of [...listeners]) {
listener(state, prev);
}
}
function subscribe(listener) {
listeners.add(listener);
return () => listeners.delete(listener); // idempotent: delete is a no-op if gone
}
return { getState, setState, subscribe };
}
module.exports = { observableStore };
The key shifts: build a new state object ({ ...prev, ...patch }) so prev is a durable snapshot; support the updater form by calling update(prev) when it's a function; and iterate a copy of the listener set so a listener can safely unsubscribe (or subscribe) during a notification.
Two subscribers, then a setState:
subscribe(A) and subscribe(B) → listeners = {A, B}.setState({ count: 1 }) on state { count: 0 }:
prev = { count: 0 }; update isn't a function, so patch = { count: 1 }.state = { ...prev, ...patch } = { count: 1 } (a new object).[A, B]; call A({count:1}, {count:0}), then B(...).A calls its unsubscribe for B inside the callback. Because we're looping the snapshot, B still runs this round; on the next setState, listeners is {A} and only A fires.Object.assign(state, patch) breaks prevState and defeats reference-equality checks (===) that consumers use to detect change. Always create a new object.[...listeners] (a snapshot).setState({ user: { name } }) replaces the whole user object; nested updates need { ...prev.user, ... } by hand or an immutable helper.count + 1 twice) need (prev) => … to read the freshest state, not a stale closed-over value.subscribe(selector, listener) and only fire when the selected slice changes (via Object.is), avoiding needless re-renders.useSyncExternalStore — React's official hook for subscribing to exactly this shape of store; wiring it up is a natural next step.setState (logging, thunks, devtools time-travel) is how Redux's applyMiddleware works.Keep practising the same patterns with a nearby challenge.
No submissions yet
Share your approach and start the discussion.
Redux, Zustand, and every "global state" hook share a tiny core: a single object holding state, a way to update it, and a list of listeners that fire whenever it changes. Strip away the middleware, selectors, and React glue and you're left with the observable store — maybe forty lines. Building it by hand is the fastest way to understand what those libraries actually do.
Implement observableStore(initialState). It returns an object with getState(), setState(update), and subscribe(listener), where setState merges an update and notifies every subscriber.
function observableStore(initialState) {
return {
getState, // () => state
setState, // (partial | (prev) => partial) => void
subscribe, // (listener) => unsubscribe
};
}
const store = observableStore({ count: 0, name: 'a' });
const off = store.subscribe((next, prev) => console.log(prev, '->', next));
store.setState({ count: 1 }); // logs {count:0,name:'a'} -> {count:1,name:'a'}
store.setState((s) => ({ count: s.count + 1 })); // updater form -> count 2
store.getState(); // { count: 2, name: 'a' }
off(); // stop listening
setState({ count: 1 }) keeps the other keys. The updater form (prev) => partial computes the patch from the current state.prevState stays a valid snapshot.listener(nextState, prevState).We'll keep state and a set of listeners in a closure; setState computes a new state and calls each listener, and subscribe hands back a way to remove one.
A store is a value plus a mailing list. getState reads the value. setState changes it and tells everyone. subscribe gets you on the list and gives you an unsubscribe. The whole design is a closure over two variables — the current state and a Set of listeners — with three small methods reading and writing them.
Think of one shared cell and a broadcast. When setState runs, it builds the next value by merging your patch onto the current state, swaps it in, then walks the listener list calling each with the new and old values. Reads (getState) never touch the listeners; writes always do.
The obvious version mutates state in place and loops the live listener set:
function observableStoreNaive(initial) {
let state = initial;
const listeners = new Set();
return {
getState: () => state,
setState(patch) {
Object.assign(state, patch); // mutates the same object
listeners.forEach((fn) => fn(state)); // no prev; iterates live set
},
subscribe(fn) { listeners.add(fn); return () => listeners.delete(fn); },
};
}
Two bugs hide here. Mutating state means a listener that stored the "previous" value now sees it changed underfoot — no reliable prevState. And iterating listeners directly while a listener might unsubscribe another causes a listener to be skipped mid-round (mutation during iteration).
function observableStore(initialState) {
let state = initialState;
const listeners = new Set();
function getState() {
return state;
}
function setState(update) {
const prev = state;
const patch = typeof update === 'function' ? update(prev) : update;
state = { ...prev, ...patch }; // new object; prev stays valid
// Snapshot first: subscribing/unsubscribing mid-round won't disturb it.
for (const listener of [...listeners]) {
listener(state, prev);
}
}
function subscribe(listener) {
listeners.add(listener);
return () => listeners.delete(listener); // idempotent: delete is a no-op if gone
}
return { getState, setState, subscribe };
}
module.exports = { observableStore };
The key shifts: build a new state object ({ ...prev, ...patch }) so prev is a durable snapshot; support the updater form by calling update(prev) when it's a function; and iterate a copy of the listener set so a listener can safely unsubscribe (or subscribe) during a notification.
Two subscribers, then a setState:
subscribe(A) and subscribe(B) → listeners = {A, B}.setState({ count: 1 }) on state { count: 0 }:
prev = { count: 0 }; update isn't a function, so patch = { count: 1 }.state = { ...prev, ...patch } = { count: 1 } (a new object).[A, B]; call A({count:1}, {count:0}), then B(...).A calls its unsubscribe for B inside the callback. Because we're looping the snapshot, B still runs this round; on the next setState, listeners is {A} and only A fires.Object.assign(state, patch) breaks prevState and defeats reference-equality checks (===) that consumers use to detect change. Always create a new object.[...listeners] (a snapshot).setState({ user: { name } }) replaces the whole user object; nested updates need { ...prev.user, ... } by hand or an immutable helper.count + 1 twice) need (prev) => … to read the freshest state, not a stale closed-over value.subscribe(selector, listener) and only fire when the selected slice changes (via Object.is), avoiding needless re-renders.useSyncExternalStore — React's official hook for subscribing to exactly this shape of store; wiring it up is a natural next step.setState (logging, thunks, devtools time-travel) is how Redux's applyMiddleware works.Keep practising the same patterns with a nearby challenge.
No submissions yet
Share your approach and start the discussion.