Build Your Own State Management
Redux's core is under 60 lines of JavaScript. Build createStore, combineReducers and a middleware system with logger and thunk from scratch, then test the parts that are easy to get quietly wrong.
Build Your Own State Management
Redux is one of the most-installed packages in the React ecosystem and one of the least-read. The core of it — createStore, dispatch, subscribe — is under 60 lines of JavaScript. The middleware system is another 20. Everything else you associate with Redux is either convention or tooling built on top of those 80 lines.
Building it from scratch is worth doing not because you'll replace Redux, but because once you see how little machinery is there, three things stop being magic: why immutability is load-bearing rather than stylistic, how the curried middleware signature actually composes, and why a store subscription has to be a snapshot-and-notify loop instead of something cleverer.
We'll build:
- A
createStorewith the same public contract as Redux's, and a test suite that proves the behaviours rather than asserting the name combineReducersfor splitting state across domains- A middleware system with
applyMiddleware - Two middlewares: a logger and a thunk (async actions)
No dependencies. Node.js, four small files.
This is a from-scratch reimplementation, not a port. Where the behaviour matches Redux I say so and the tests pin it; where I haven't checked Redux's source I don't guess. "Redux-compatible" is a claim you can only make by running someone's test suite, and Redux doesn't publish one for third-party stores.
The Shape of the Thing
Before writing code, here's what we're building:
State is a single plain object. Reducers are pure functions that take (state, action) and return a new state. dispatch is the only way to change state. Listeners are called after every state change. That's the whole contract.
Step 1: The Core Store
Implement createStore
// store.js
function createStore(reducer, preloadedState, enhancer) {
// Support (reducer, enhancer) signature — same as Redux
if (typeof preloadedState === 'function' && typeof enhancer === 'undefined') {
enhancer = preloadedState;
preloadedState = undefined;
}
// If an enhancer (like applyMiddleware) is provided, let it wrap createStore
if (typeof enhancer !== 'undefined') {
return enhancer(createStore)(reducer, preloadedState);
}
let state = preloadedState;
let listeners = [];
let isDispatching = false;
function getState() {
if (isDispatching) {
throw new Error('Cannot call getState() while reducer is executing.');
}
return state;
}
function subscribe(listener) {
if (typeof listener !== 'function') {
throw new Error('Listener must be a function.');
}
if (isDispatching) {
throw new Error('Cannot subscribe while reducer is executing.');
}
let isSubscribed = true;
listeners.push(listener);
// Return an unsubscribe function
return function unsubscribe() {
if (!isSubscribed) return;
if (isDispatching) {
throw new Error('Cannot unsubscribe while reducer is executing.');
}
isSubscribed = false;
const index = listeners.indexOf(listener);
listeners.splice(index, 1);
};
}
function dispatch(action) {
if (typeof action !== 'object' || action === null || !('type' in action)) {
throw new Error('Actions must be plain objects with a "type" property.');
}
if (isDispatching) {
throw new Error('Reducers may not dispatch actions.');
}
try {
isDispatching = true;
state = reducer(state, action);
} finally {
isDispatching = false;
}
// Snapshot listeners before notifying — a listener could subscribe/unsubscribe
const currentListeners = [...listeners];
for (const listener of currentListeners) {
listener();
}
return action;
}
// Kick off the initial state by dispatching a dummy action
dispatch({ type: '@@STORE/INIT' });
return { getState, dispatch, subscribe };
}
module.exports = { createStore };The isDispatching guard deserves attention. If your reducer called store.dispatch() while it was running, you'd get infinite recursion and silent state corruption. The flag makes that an explicit error instead of a silent footgun.
The @@STORE/INIT dispatch at the bottom is how state gets initialized — the reducer receives undefined as current state, hits its default parameter, and returns the initial slice. Every reducer runs once before any user code touches the store.
The try/finally around the reducer call is doing more than it looks like. Without the finally, a reducer that throws leaves isDispatching stuck at true forever, and from then on every getState() throws "Cannot call getState() while reducer is executing" — a store bricked by an error that already propagated to the caller and looked handled.
getState() returns the state object itself, not a copy. Nothing stops a caller doing store.getState().user.name = 'x', and if they do, the mutation lands in the store with no dispatch, no notification, and no === change for any consumer to detect. Redux behaves the same way and for the same reason — deep-cloning on every read would be absurd — but it means "immutability" here is a convention the reducers keep, not an invariant the store enforces. If you want it enforced, freeze in development: Object.freeze in getState, or deepFreeze on the state returned by the reducer.
Step 2: Splitting State with combineReducers
Real apps don't have a single monolithic reducer. combineReducers lets you split state into slices, each managed by its own function.
Implement combineReducers
// combine-reducers.js
function combineReducers(reducers) {
const reducerKeys = Object.keys(reducers);
// Validate at creation time, not dispatch time
for (const key of reducerKeys) {
if (typeof reducers[key] !== 'function') {
throw new Error(`Reducer for key "${key}" is not a function.`);
}
}
return function combination(state = {}, action) {
let hasChanged = false;
const nextState = {};
for (const key of reducerKeys) {
const reducer = reducers[key];
const previousStateForKey = state[key];
const nextStateForKey = reducer(previousStateForKey, action);
if (typeof nextStateForKey === 'undefined') {
throw new Error(
`Reducer for key "${key}" returned undefined for action "${action.type}". ` +
`Return null if you want empty state, not undefined.`
);
}
nextState[key] = nextStateForKey;
hasChanged = hasChanged || nextStateForKey !== previousStateForKey;
}
hasChanged = hasChanged || reducerKeys.length !== Object.keys(state).length;
return hasChanged ? nextState : state;
};
}
module.exports = { combineReducers };The hasChanged check is the performance trick Redux gets credit for. If no reducer returned a different object reference, combination returns the same state object. This means === checks in UI frameworks (React's shouldComponentUpdate, useMemo dependencies) can bail out without deep equality. If your reducers mutate state in place and return the same reference — the most common Redux mistake — nothing ever updates.
Let's wire these two together to test what we have so far:
// demo-basic.js
const { createStore } = require('./store');
const { combineReducers } = require('./combine-reducers');
function todosReducer(state = [], action) {
switch (action.type) {
case 'ADD_TODO':
return [...state, { id: Date.now(), text: action.text, done: false }];
case 'TOGGLE_TODO':
return state.map(t => t.id === action.id ? { ...t, done: !t.done } : t);
default:
return state;
}
}
function filterReducer(state = 'ALL', action) {
switch (action.type) {
case 'SET_FILTER':
return action.filter;
default:
return state;
}
}
const rootReducer = combineReducers({
todos: todosReducer,
filter: filterReducer,
});
const store = createStore(rootReducer);
const unsubscribe = store.subscribe(() => {
console.log('State changed:', JSON.stringify(store.getState(), null, 2));
});
store.dispatch({ type: 'ADD_TODO', text: 'Build state management' });
store.dispatch({ type: 'ADD_TODO', text: 'Ship it' });
store.dispatch({ type: 'TOGGLE_TODO', id: store.getState().todos[0].id });
store.dispatch({ type: 'SET_FILTER', filter: 'ACTIVE' });
unsubscribe();
store.dispatch({ type: 'ADD_TODO', text: 'This fires but nobody is listening' });Step 3: The Middleware System
This is where Redux's design really shines — and where most explanations lose people.
Middleware is a way to wrap dispatch with additional behavior: logging, async handling, crash reporting. The key insight: middleware doesn't modify dispatch on the store object. It creates a new dispatch function that calls the original.
Implement compose and applyMiddleware
// middleware.js
// compose(f, g, h)(x) === f(g(h(x)))
function compose(...fns) {
if (fns.length === 0) return (arg) => arg;
if (fns.length === 1) return fns[0];
return fns.reduce((a, b) => (...args) => a(b(...args)));
}
function applyMiddleware(...middlewares) {
// applyMiddleware returns an enhancer — a function that wraps createStore
return function enhancer(createStore) {
return function enhancedCreateStore(reducer, preloadedState) {
const store = createStore(reducer, preloadedState);
// Give each middleware access to getState and a preliminary dispatch
// (the preliminary dispatch throws if called before setup is done)
let dispatch = () => {
throw new Error('Dispatching while constructing your middleware is not allowed.');
};
const middlewareAPI = {
getState: store.getState,
dispatch: (...args) => dispatch(...args),
};
// Each middleware gets the API and returns a function that takes "next"
const chain = middlewares.map((middleware) => middleware(middlewareAPI));
// Compose them into a single dispatch function
dispatch = compose(...chain)(store.dispatch);
return { ...store, dispatch };
};
};
}
module.exports = { applyMiddleware, compose };The middlewareAPI.dispatch is a wrapper around dispatch — not store.dispatch. This lets a middleware like thunk call dispatch and hit the full middleware chain again (so async actions can dispatch other async actions). If it captured store.dispatch directly, it would skip all middleware.
Step 4: Two Useful Middlewares
Logger and thunk middlewares
// middlewares.js
// Logger: prints every action and the resulting state
const loggerMiddleware = (store) => (next) => (action) => {
console.group(action.type);
console.log(' dispatching:', action);
const result = next(action); // Let the action through
console.log(' next state:', store.getState());
console.groupEnd();
return result;
};
// Thunk: allows dispatching functions (for async work)
// If the action is a function, call it with (dispatch, getState).
// Otherwise pass it straight to the next middleware.
const thunkMiddleware = (store) => (next) => (action) => {
if (typeof action === 'function') {
return action(store.dispatch, store.getState);
}
return next(action);
};
module.exports = { loggerMiddleware, thunkMiddleware };The curried signature — store => next => action => ... — is the middleware contract. Each middleware is a function that takes store, returns a function that takes next (the next middleware's dispatch), returns a function that handles each action.
When compose chains these together, each middleware's next is the one to its right. The rightmost middleware's next is store.dispatch. So applyMiddleware(thunkMiddleware, loggerMiddleware) builds thunk(logger(store.dispatch)) — thunk sees every action first, and the logger only ever sees plain objects, because a function action is swallowed by thunk and never passed to next. Reverse the two arguments and your log fills up with [Function (anonymous)].
Step 5: Full Demo
Wire everything together
// demo.js
const { createStore } = require('./store');
const { combineReducers } = require('./combine-reducers');
const { applyMiddleware } = require('./middleware');
const { loggerMiddleware, thunkMiddleware } = require('./middlewares');
// --- Reducers ---
function usersReducer(state = { list: [], loading: false }, action) {
switch (action.type) {
case 'FETCH_USERS_START':
return { ...state, loading: true };
case 'FETCH_USERS_SUCCESS':
return { list: action.users, loading: false };
case 'FETCH_USERS_FAILURE':
return { ...state, loading: false };
default:
return state;
}
}
function notificationsReducer(state = [], action) {
switch (action.type) {
case 'ADD_NOTIFICATION':
return [...state, { id: Date.now(), message: action.message }];
case 'DISMISS_NOTIFICATION':
return state.filter((n) => n.id !== action.id);
default:
return state;
}
}
const rootReducer = combineReducers({
users: usersReducer,
notifications: notificationsReducer,
});
// --- Store ---
const store = createStore(
rootReducer,
applyMiddleware(thunkMiddleware, loggerMiddleware)
// Order matters: thunk runs first so it can intercept function-actions
// before they reach the logger
);
// --- Action Creators ---
function fetchUsers() {
// A thunk: returns a function instead of a plain action object
return async (dispatch, getState) => {
dispatch({ type: 'FETCH_USERS_START' });
try {
// Simulate an API call
const users = await new Promise((resolve) =>
setTimeout(() => resolve([
{ id: 1, name: 'Alice' },
{ id: 2, name: 'Bob' },
]), 200)
);
dispatch({ type: 'FETCH_USERS_SUCCESS', users });
dispatch({
type: 'ADD_NOTIFICATION',
message: `Loaded ${users.length} users`,
});
} catch (err) {
dispatch({ type: 'FETCH_USERS_FAILURE' });
}
};
}
// --- Run It ---
store.subscribe(() => {
const { users, notifications } = store.getState();
if (!users.loading && users.list.length > 0) {
console.log('\nFinal state:');
console.log(' Users:', users.list.map((u) => u.name));
console.log(' Notifications:', notifications.map((n) => n.message));
}
});
store.dispatch(fetchUsers());Run it with node demo.js. The logger prints each action as it passes through the chain and the subscriber prints twice. Here is the whole thing, verbatim — the notification id is Date.now() so it changes per run, and [Object] is just util.inspect's default depth limit:
FETCH_USERS_START
dispatching: { type: 'FETCH_USERS_START' }
next state: { users: { list: [], loading: true }, notifications: [] }
FETCH_USERS_SUCCESS
dispatching: {
type: 'FETCH_USERS_SUCCESS',
users: [ { id: 1, name: 'Alice' }, { id: 2, name: 'Bob' } ]
}
Final state:
Users: [ 'Alice', 'Bob' ]
Notifications: []
next state: {
users: { list: [ [Object], [Object] ], loading: false },
notifications: []
}
ADD_NOTIFICATION
dispatching: { type: 'ADD_NOTIFICATION', message: 'Loaded 2 users' }
Final state:
Users: [ 'Alice', 'Bob' ]
Notifications: [ 'Loaded 2 users' ]
next state: {
users: { list: [ [Object], [Object] ], loading: false },
notifications: [ { id: 1785673103042, message: 'Loaded 2 users' } ]
}Three things to notice in that output.
The subscriber is called three times but prints twice. There are three dispatches, dispatch notifies after every one of them, and the listener runs each time — the missing print is FETCH_USERS_START, which the subscriber's own if (!users.loading && users.list.length > 0) guard filters out. That gap between "ran" and "printed" is the point: the store has no idea what a subscriber cares about, so every subscriber needs its own bail-out. (This demo doesn't show the harsher version of that, because all three actions do change state. Dispatch an action no reducer handles and the listener still fires.)
The logger's next state: line for FETCH_USERS_SUCCESS appears after the subscriber output, because listeners run inside dispatch, before next(action) returns and the logger gets to look at the store again.
And the subscriber's own output is indented two spaces, sitting under the logger's console.group — the group is still open while listeners run, for the same reason. console.group indentation is a global on the console object, so anything that logs inside a dispatch inherits it.
Proving the Behaviour
"It behaves like Redux" is not a claim you can make from reading. It's also not what most articles test — they test that the happy path works, which any implementation passes. The interesting assertions are the guards and the identity semantics, because those are the parts you'd get wrong and not notice.
// store-tests.js
const { createStore } = require('./store');
const { combineReducers } = require('./combine-reducers');
const { applyMiddleware } = require('./middleware');
const { thunkMiddleware } = require('./middlewares');
// A throwing assert. console.assert() only logs in Node, so a broken store
// would still reach the "all passed" line at the bottom.
const assert = (cond, msg) => { if (!cond) throw new Error('FAILED: ' + msg); };
// A throwing assert for the error paths. A bare `catch { return; }` would also
// pass on a ReferenceError from a typo inside the callback, so this pins two
// things: the error is a plain Error (not a TypeError or ReferenceError the code
// threw by accident) and its message is the one the guard actually produces.
const throws = (fn, expected, msg) => {
let err;
try { fn(); } catch (e) { err = e; }
if (err === undefined) throw new Error('FAILED: expected a throw — ' + msg);
if (err.constructor !== Error) {
throw new Error(`FAILED: expected Error, got ${err.constructor.name} — ${msg}`);
}
if (!err.message.includes(expected)) {
throw new Error(`FAILED: expected message containing "${expected}", got "${err.message}" — ${msg}`);
}
};
const counter = (state = 0, action) => (action.type === 'INC' ? state + 1 : state);
// --- store basics
assert(createStore(counter).getState() === 0, 'INIT runs the reducer for initial state');
assert(createStore(counter, 7).getState() === 7, 'preloadedState wins over the default');
{
const store = createStore(counter);
const action = { type: 'INC' };
assert(store.dispatch(action) === action, 'dispatch returns the action');
assert(store.getState() === 1, 'state advanced');
}
throws(() => createStore(counter).dispatch('INC'),
'Actions must be plain objects', 'string actions are rejected');
throws(() => createStore(counter).dispatch({}),
'Actions must be plain objects', 'actions need a type');
// --- subscribe / unsubscribe
{
const store = createStore(counter);
let calls = 0;
const unsubscribe = store.subscribe(() => calls++);
store.dispatch({ type: 'INC' });
unsubscribe();
store.dispatch({ type: 'INC' });
unsubscribe(); // second call must be a no-op, not a splice of someone else
assert(calls === 1, 'unsubscribed listener stops firing');
}
{
// A listener that subscribes must not fire in the round that added it
const store = createStore(counter);
let late = 0;
store.subscribe(() => { store.subscribe(() => late++); });
store.dispatch({ type: 'INC' });
assert(late === 0, 'new subscriber waits for the next dispatch');
}
// --- reducer sandbox
{
let caught = null;
const store = createStore((state = {}, action) => {
if (action.type === 'X') {
try { store.dispatch({ type: 'Y' }); } catch (e) { caught = e; }
}
return state;
});
store.dispatch({ type: 'X' });
assert(caught && caught.message.includes('Reducers may not dispatch'),
'a reducer cannot dispatch, got: ' + (caught && caught.message));
}
{
// A throwing reducer must not leave isDispatching stuck at true
let bomb = false;
const store = createStore((state = 0) => { if (bomb) throw new Error('boom'); return state; });
bomb = true;
try { store.dispatch({ type: 'A' }); } catch {}
bomb = false;
store.getState(); // would throw if the finally block were missing
store.dispatch({ type: 'A' });
}
// --- combineReducers
{
const store = createStore(combineReducers({ a: counter, b: counter }));
const before = store.getState();
store.dispatch({ type: 'NOTHING_MATCHES' });
assert(store.getState() === before, 'unchanged slices return the identical state object');
store.dispatch({ type: 'INC' });
assert(store.getState() !== before && store.getState().a === 1, 'changed slices produce a new object');
}
throws(() => createStore(combineReducers({ a: () => undefined })),
'returned undefined', 'undefined from a slice is an error');
throws(() => combineReducers({ a: 1 }),
'is not a function', 'non-function slice is rejected at creation');
// --- middleware
{
const order = [];
const tap = (tag) => () => (next) => (action) => { order.push(tag); return next(action); };
const store = createStore(counter, applyMiddleware(tap('1'), tap('2')));
store.dispatch({ type: 'INC' });
assert(order.join() === '1,2', 'middleware runs left to right, got ' + order.join());
assert(store.getState() === 1, 'the action still reached the reducer');
}
{
// A thunk's dispatch must re-enter the whole chain, not skip to store.dispatch
const seen = [];
const spy = () => (next) => (action) => { seen.push(action.type); return next(action); };
const store = createStore(counter, applyMiddleware(thunkMiddleware, spy));
store.dispatch((dispatch) => dispatch({ type: 'INC' }));
assert(seen.join() === 'INC', 'nested dispatch skipped the chain: ' + seen.join());
}
{
const store = createStore(counter, applyMiddleware(thunkMiddleware));
let calls = 0;
store.subscribe(() => calls++);
store.dispatch({ type: 'INC' });
assert(calls === 1, 'subscribe survives the enhancer');
}
// The canary: if assert() ever stops throwing, everything above is decoration.
let harnessWorks = false;
try { assert(false, 'canary'); } catch { harnessWorks = true; }
assert(harnessWorks, 'assert() does not throw');
// Second canary: throws() must reject a wrong error type, otherwise every
// error-path assertion above is satisfiable by accident.
let throwsIsStrict = false;
try { throws(() => { undefinedIdentifier; }, 'Actions must be plain objects', 'canary'); }
catch (e) { throwsIsStrict = e.message.includes('got ReferenceError'); }
assert(throwsIsStrict, 'throws() accepts a ReferenceError as a pass');
console.log('All assertions passed.');node store-tests.js — nineteen assertions plus the two canaries, exits non-zero on the first failure. The way to find out whether a suite like this is load-bearing is to break the code on purpose and check that it complains. Retyping the dispatch guard as throw notAnError; (a bare undefined identifier) is the interesting mutation: the earlier catch { return; } version of throws reports all green on that mutant, because a ReferenceError is still a throw. The version above fails with expected Error, got ReferenceError. Reword a guard's message and it fails on the message; delete combineReducers's undefined-slice check and it fails with expected a throw.
Two assertions are worth calling out separately because they encode decisions rather than correctness:
Unchanged slices must return the identical object. combineReducers builds a fresh nextState on every dispatch and then throws it away if no slice's reference changed. Without that, every dispatch produces a new root object, every === comparison downstream reports "changed", and the memoisation the whole pattern depends on silently stops working while everything still renders correctly. It's a performance bug that no functional test catches.
A listener that subscribes must not fire in the same round. dispatch copies the listener array before iterating (const currentListeners = [...listeners]). The copy also means the reverse: unsubscribing during a notification does not cancel the call that's already in flight for that round. Both halves of that are deliberate — a listener list that mutates while you iterate it is how you get skipped listeners and index bugs — but the consequence is that an unsubscribed listener can be called once more, so listeners have to tolerate running after teardown.
What We Skipped
Counting non-blank, non-comment lines: 59 for the store, 28 for combineReducers, 23 for compose + applyMiddleware, 15 for the two middlewares. That's the core pattern. Here's what the real Redux adds:
replaceReducer— hot-reload support; swaps the reducer at runtime. Useful for code-split apps that load reducers lazilySymbol.observable— interop with RxJS and other observable libraries; the store can be converted to an observable stream- A cheaper subscriber snapshot — We copy the whole array on every dispatch. Redux 5 keeps two references (
currentListeners/nextListeners) and only clones when a subscription changes mid-notification, so a store with a stable listener set never copies at all. Same semantics, less garbage - Mutation detection — Worth being precise about, because it's commonly misattributed: plain
redux'screateStoredoes not check whether your reducer mutated state. There is no such code in it. The check lives in Redux Toolkit —configureStoreinstallsimmutableStateInvariantMiddlewarein development, which walks the state tree and records it before the reducer runs, walks it again after, and throws if anything changed in place. It's a deep comparison on every dispatch, which is why it's development-only redux-toolkit— the modern Redux isconfigureStore+createSlicefrom RTK. The underlying store is the same; RTK just removes the boilerplate of writing action type strings and spread-cloning in every case branch- Persistence —
redux-persistrehydrates state from localStorage on startup. With our implementation you'd wire this as a subscriber that serializes state on every change, andpreloadedStateon creation - Selectors —
reselectmemoizes derived state. Nothing in our store needs to change; selectors are just functions overgetState() - A React binding — deliberately out of scope, and the one place where a from-scratch version is genuinely easy to get wrong today. The
useState+useEffectshape that every "connect your own store to React" tutorial shows is not correct on React 18 or 19: with concurrent rendering it can read state during render that a concurrent update has already changed, which is the tearing problemuseSyncExternalStorewas added to solve. If you write your own binding,useSyncExternalStore(store.subscribe, store.getState)is the whole thing, and thegetSnapshotyou pass it must return a cached reference — returning a fresh object from a selector on every call makes React re-render forever. react-redux has useduseSyncExternalStoresince v8 for the same reason
Three decisions this exercise should leave you with an opinion on:
- Does your store notify on every dispatch, or only on change? Ours notifies unconditionally, like Redux, which pushes the bail-out into every subscriber. Storing the previous state and comparing in
dispatchmoves that cost to one place — but then "changed" means reference equality on the root, and you've madecombineReducers's identity behaviour load-bearing for correctness rather than just performance. - Is
getState()a snapshot or a live reference? Ours is live. That's fast and it makes immutability a convention rather than a guarantee. - Where does async live? Thunks put it in action creators, which keeps the store synchronous and untestable-in-isolation. Sagas and observables move it out into a separate process with its own vocabulary. Neither is wrong; picking by "which one is more popular" is.
The library is thin on purpose. What's thick is the set of conventions around it, and those are the part you can't read out of the source.
Comments (0)
No comments yet. Be the first to share your thoughts!