Redux is a state container: one plain object holds the whole app state, the only way to change it is to dispatch an action through a pure reducer, and any number of listeners can subscribe to be told when it changes. You will build that core plus the two helpers every real Redux app leans on, combineReducers and applyMiddleware, exposed as one object miniRedux.
createStore(reducer, preloadedState?, enhancer?) returns a store. dispatch(action) computes the next state with reducer(state, action) and then synchronously runs every subscriber. subscribe(listener) returns a function that unsubscribes. combineReducers(map) splices many small reducers into one. applyMiddleware(...mws) is an enhancer that wraps dispatch in a chain of middleware.
type Action = { type: string; [key: string]: unknown };
type Reducer<S> = (state: S | undefined, action: Action) => S;
type Middleware = (api: { getState(): unknown; dispatch(a: Action): Action }) =>
(next: (a: Action) => Action) => (action: Action) => Action;
const miniRedux: {
createStore<S>(reducer: Reducer<S>, preloadedState?: S, enhancer?: Function): {
getState(): S;
dispatch(action: Action): Action;
subscribe(listener: () => void): () => void; // returns unsubscribe
};
combineReducers(map: Record<string, Reducer<unknown>>): Reducer<Record<string, unknown>>;
applyMiddleware(...mws: Middleware[]): Function; // an enhancer for createStore
};
const { createStore } = miniRedux;
const counter = (state = 0, action) =>
action.type === 'INC' ? state + 1 : state;
const store = createStore(counter);
store.getState(); // 0 — the reducer's initial state
const unsubscribe = store.subscribe(() => console.log(store.getState()));
store.dispatch({ type: 'INC' }); // logs 1
store.dispatch({ type: 'INC' }); // logs 2
unsubscribe();
store.dispatch({ type: 'INC' }); // state is 3, but nothing logs
const { createStore, combineReducers, applyMiddleware } = miniRedux;
const logger = (store) => (next) => (action) => {
const result = next(action);
console.log(action.type, store.getState());
return result;
};
const root = combineReducers({ count: counter });
const store = createStore(root, applyMiddleware(logger));
store.dispatch({ type: 'INC' }); // logs: INC { count: 1 }
store.getState(); // { count: 1 }
dispatch runs the reducer and notifies every subscriber before it returns. There are no timers and nothing is async.combineReducers returns the exact same state object when no slice changed, so reference-equality consumers can skip work.store => next => action => {}, composed right-to-left, and each one receives { getState, dispatch } where dispatch routes through the whole chain.replaceReducer, time-travel, or React bindings. The three functions above are the whole surface.You are building the core of Redux: a store that holds one state tree, changes it only through a pure reducer, and lets any number of listeners subscribe to those changes, plus the two helpers that make it scale, combineReducers and applyMiddleware.
Your app has one big state object: the logged-in user, the cart, the current route. Anywhere in the UI can ask to change it (add to cart) and anywhere else needs to hear about the change (update the cart badge). Redux is one pattern for that. There is a single store; the only way to change it is to dispatch a plain action object, which the store feeds to a pure reducer to get the next state; and a subscribe list lets views react. Your job is the three load-bearing pieces: createStore, combineReducers, and applyMiddleware.
Think of dispatch as a two-step move that never reorders: first it computes the next state by running reducer(state, action), then it notifies every subscriber. The order matters. Subscribers read the store with getState, so the state has to be updated before you tell anyone about it, or they all read the stale value.
The obvious store keeps a state variable and a list of listeners, and wires up dispatch:
function createStore(reducer, preloadedState) {
let state = preloadedState;
const listeners = [];
return {
getState: () => state,
dispatch(action) {
listeners.forEach((fn) => fn()); // tell everyone...
state = reducer(state, action); // ...then update. Backwards!
},
subscribe(fn) {
listeners.push(fn); // but how do you ever remove fn?
},
};
}
This has three bugs. It notifies before computing the next state, so every subscriber reads the old value with getState and the UI paints stale data. subscribe returns nothing, so a listener can never be removed: the list only grows, and a component that mounts and unmounts keeps getting called forever. And nothing primes the reducer, so getState is undefined until the first dispatch instead of starting at the reducer's initial state.
Compute the next state first, then notify. Return an unsubscribe function from subscribe. Prime the store with one throwaway action so getState starts at the reducer's initial value.
// compose(a, b, c) => (...args) => a(b(c(...args))) — apply right-to-left.
function compose(...fns) {
if (fns.length === 0) return (x) => x;
if (fns.length === 1) return fns[0];
return fns.reduce((a, b) => (...args) => a(b(...args)));
}
function createStore(reducer, preloadedState, enhancer) {
// createStore(reducer, enhancer): if the 2nd arg is a function and there is
// no 3rd, treat it as the enhancer so applyMiddleware can be passed here.
if (typeof preloadedState === 'function' && enhancer === undefined) {
enhancer = preloadedState;
preloadedState = undefined;
}
// An enhancer (like applyMiddleware(...)) wraps the whole store.
if (typeof enhancer === 'function') {
return enhancer(createStore)(reducer, preloadedState);
}
let state = preloadedState;
let listeners = [];
function getState() {
return state;
}
function dispatch(action) {
state = reducer(state, action); // 1. compute the next state FIRST
// 2. THEN notify. Iterate a copy so a listener that unsubscribes
// itself mid-notify does not corrupt the loop.
for (const listener of listeners.slice()) listener();
return action;
}
function subscribe(listener) {
listeners.push(listener);
let subscribed = true;
return function unsubscribe() {
if (!subscribed) return; // calling twice is harmless
subscribed = false;
listeners = listeners.filter((l) => l !== listener);
};
}
// Prime every reducer with an unknown action so getState() starts at the
// reducer's initial state rather than undefined.
dispatch({ type: '@@redux/INIT' });
return { getState, dispatch, subscribe };
}
function combineReducers(reducers) {
const keys = Object.keys(reducers);
return function combined(state = {}, action) {
const next = {};
let changed = false;
for (const key of keys) {
const prevSlice = state[key];
const nextSlice = reducers[key](prevSlice, action);
next[key] = nextSlice;
changed = changed || nextSlice !== prevSlice; // did THIS slice change?
}
return changed ? next : state; // nothing changed → hand back the SAME object
};
}
function applyMiddleware(...middlewares) {
return function enhancer(createStore) {
return function (reducer, preloadedState) {
const store = createStore(reducer, preloadedState);
let dispatch = () => {
throw new Error('dispatching during middleware setup is not allowed');
};
// Each middleware gets { getState, dispatch }, where dispatch routes
// through the WHOLE chain — so a middleware can re-dispatch.
const api = {
getState: store.getState,
dispatch: (action) => dispatch(action),
};
const chain = middlewares.map((mw) => mw(api));
dispatch = compose(...chain)(store.dispatch);
return { ...store, dispatch };
};
};
}
const miniRedux = { createStore, combineReducers, applyMiddleware };
module.exports = { miniRedux };
The one shift in createStore is order: state = reducer(state, action) runs before the notify loop, so every listener that calls getState sees the new value. subscribe hands back a closure that flips a subscribed flag and filters the listener out, so unmounting is one call and calling it twice is safe. The final dispatch({ type: '@@redux/INIT' }) is the standard trick to make every reducer return its default state once, so the store is never undefined.
combineReducers is where the reference-equality lesson lives. It runs each slice reducer on its own piece of state and collects the results into next. The changed flag tracks whether any slice returned a different reference. If one did, it returns the new next object; if every slice returned the exact value it already held, it returns the original state untouched, so a consumer comparing references knows nothing changed and can skip its work.
applyMiddleware is an enhancer: a function that takes createStore and returns a souped-up one. It builds the real store, then replaces its dispatch with a chain. Each middleware is called with api to get an action => {} handler, and compose nests those handlers so the rightmost wraps the base store.dispatch first. The key detail is that api.dispatch points at the finished chain, not store.dispatch, so a middleware that re-dispatches sends the action back through the whole chain rather than skipping straight to the reducer.
Take a store built from two slices and dispatch one action:
const store = createStore(combineReducers({ count: counter, todos }));
store.dispatch({ type: 'ADD_TODO', text: 'milk' });
state is { count: 0, todos: [] }. Each slice reducer turned its undefined slice into a default, so changed was true and the combined reducer built that first object.ADD_TODO action reaches the combined reducer. It runs counter(0, action), which returns 0 again (the counter ignores ADD_TODO), and todos([], action), which returns a brand-new array ['milk'].count slice is referentially identical, but the todos slice is a new array, so changed is true and a new root { count: 0, todos: ['milk'] } is returned. Notice count was carried straight over by value.dispatch, state is now that new object, and only then does the notify loop run. Any subscriber that calls getState reads { count: 0, todos: ['milk'] }, never the pre-update value.{ type: 'INC' } next. Now counter(0, ...) returns 1 (new) and todos(['milk'], ...) returns the same array by reference. changed is true, so a new root is built, but its todos is the identical array from before, shared by reference. A memoized todo list sees an unchanged reference and skips re-rendering.state = reducer(...), every subscriber reads the previous state through getState and the UI shows stale data. Fix: compute the next state first, then notify.subscribe with no way to unsubscribe — pushing the listener and returning nothing leaks; an unmounted component keeps being called on every dispatch. Fix: return a function that removes exactly that listener.combineReducers allocating on every action — spreading into a fresh object unconditionally hands back a new reference even when nothing changed, so prev === next is never true and every consumer re-runs. Fix: track a changed flag per slice and return the original state when all slices are referentially equal.next with store.dispatch in middleware — next(action) passes the action down the rest of the chain toward the reducer; store.dispatch(action) sends it back to the top of the chain. Re-dispatching with next silently skips every middleware above you. Use store.dispatch to start over, next to continue.state.count++; return state keeps the same reference, so combineReducers decides the slice is unchanged and drops the update. Fix: return a new value for the slice you change.replaceReducer — expose a method that swaps the reducer at runtime so you can code-split and lazy-load feature reducers into the store.dispatch accept a function (dispatch, getState) => {} for async flows; it is about a dozen lines on top of what you have here.combineReducers also compares the number of keys and warns when the state carries a key no reducer owns, which catches typos in your slice names.Symbol.observable interop and libraries like React-Redux build useSelector on top of the subscribe primitive with an equality check; the subscribe hook here is where all of that attaches.Keep practising the same patterns with a nearby challenge.
No submissions yet
Share your approach and start the discussion.
Redux is a state container: one plain object holds the whole app state, the only way to change it is to dispatch an action through a pure reducer, and any number of listeners can subscribe to be told when it changes. You will build that core plus the two helpers every real Redux app leans on, combineReducers and applyMiddleware, exposed as one object miniRedux.
createStore(reducer, preloadedState?, enhancer?) returns a store. dispatch(action) computes the next state with reducer(state, action) and then synchronously runs every subscriber. subscribe(listener) returns a function that unsubscribes. combineReducers(map) splices many small reducers into one. applyMiddleware(...mws) is an enhancer that wraps dispatch in a chain of middleware.
type Action = { type: string; [key: string]: unknown };
type Reducer<S> = (state: S | undefined, action: Action) => S;
type Middleware = (api: { getState(): unknown; dispatch(a: Action): Action }) =>
(next: (a: Action) => Action) => (action: Action) => Action;
const miniRedux: {
createStore<S>(reducer: Reducer<S>, preloadedState?: S, enhancer?: Function): {
getState(): S;
dispatch(action: Action): Action;
subscribe(listener: () => void): () => void; // returns unsubscribe
};
combineReducers(map: Record<string, Reducer<unknown>>): Reducer<Record<string, unknown>>;
applyMiddleware(...mws: Middleware[]): Function; // an enhancer for createStore
};
const { createStore } = miniRedux;
const counter = (state = 0, action) =>
action.type === 'INC' ? state + 1 : state;
const store = createStore(counter);
store.getState(); // 0 — the reducer's initial state
const unsubscribe = store.subscribe(() => console.log(store.getState()));
store.dispatch({ type: 'INC' }); // logs 1
store.dispatch({ type: 'INC' }); // logs 2
unsubscribe();
store.dispatch({ type: 'INC' }); // state is 3, but nothing logs
const { createStore, combineReducers, applyMiddleware } = miniRedux;
const logger = (store) => (next) => (action) => {
const result = next(action);
console.log(action.type, store.getState());
return result;
};
const root = combineReducers({ count: counter });
const store = createStore(root, applyMiddleware(logger));
store.dispatch({ type: 'INC' }); // logs: INC { count: 1 }
store.getState(); // { count: 1 }
dispatch runs the reducer and notifies every subscriber before it returns. There are no timers and nothing is async.combineReducers returns the exact same state object when no slice changed, so reference-equality consumers can skip work.store => next => action => {}, composed right-to-left, and each one receives { getState, dispatch } where dispatch routes through the whole chain.replaceReducer, time-travel, or React bindings. The three functions above are the whole surface.You are building the core of Redux: a store that holds one state tree, changes it only through a pure reducer, and lets any number of listeners subscribe to those changes, plus the two helpers that make it scale, combineReducers and applyMiddleware.
Your app has one big state object: the logged-in user, the cart, the current route. Anywhere in the UI can ask to change it (add to cart) and anywhere else needs to hear about the change (update the cart badge). Redux is one pattern for that. There is a single store; the only way to change it is to dispatch a plain action object, which the store feeds to a pure reducer to get the next state; and a subscribe list lets views react. Your job is the three load-bearing pieces: createStore, combineReducers, and applyMiddleware.
Think of dispatch as a two-step move that never reorders: first it computes the next state by running reducer(state, action), then it notifies every subscriber. The order matters. Subscribers read the store with getState, so the state has to be updated before you tell anyone about it, or they all read the stale value.
The obvious store keeps a state variable and a list of listeners, and wires up dispatch:
function createStore(reducer, preloadedState) {
let state = preloadedState;
const listeners = [];
return {
getState: () => state,
dispatch(action) {
listeners.forEach((fn) => fn()); // tell everyone...
state = reducer(state, action); // ...then update. Backwards!
},
subscribe(fn) {
listeners.push(fn); // but how do you ever remove fn?
},
};
}
This has three bugs. It notifies before computing the next state, so every subscriber reads the old value with getState and the UI paints stale data. subscribe returns nothing, so a listener can never be removed: the list only grows, and a component that mounts and unmounts keeps getting called forever. And nothing primes the reducer, so getState is undefined until the first dispatch instead of starting at the reducer's initial state.
Compute the next state first, then notify. Return an unsubscribe function from subscribe. Prime the store with one throwaway action so getState starts at the reducer's initial value.
// compose(a, b, c) => (...args) => a(b(c(...args))) — apply right-to-left.
function compose(...fns) {
if (fns.length === 0) return (x) => x;
if (fns.length === 1) return fns[0];
return fns.reduce((a, b) => (...args) => a(b(...args)));
}
function createStore(reducer, preloadedState, enhancer) {
// createStore(reducer, enhancer): if the 2nd arg is a function and there is
// no 3rd, treat it as the enhancer so applyMiddleware can be passed here.
if (typeof preloadedState === 'function' && enhancer === undefined) {
enhancer = preloadedState;
preloadedState = undefined;
}
// An enhancer (like applyMiddleware(...)) wraps the whole store.
if (typeof enhancer === 'function') {
return enhancer(createStore)(reducer, preloadedState);
}
let state = preloadedState;
let listeners = [];
function getState() {
return state;
}
function dispatch(action) {
state = reducer(state, action); // 1. compute the next state FIRST
// 2. THEN notify. Iterate a copy so a listener that unsubscribes
// itself mid-notify does not corrupt the loop.
for (const listener of listeners.slice()) listener();
return action;
}
function subscribe(listener) {
listeners.push(listener);
let subscribed = true;
return function unsubscribe() {
if (!subscribed) return; // calling twice is harmless
subscribed = false;
listeners = listeners.filter((l) => l !== listener);
};
}
// Prime every reducer with an unknown action so getState() starts at the
// reducer's initial state rather than undefined.
dispatch({ type: '@@redux/INIT' });
return { getState, dispatch, subscribe };
}
function combineReducers(reducers) {
const keys = Object.keys(reducers);
return function combined(state = {}, action) {
const next = {};
let changed = false;
for (const key of keys) {
const prevSlice = state[key];
const nextSlice = reducers[key](prevSlice, action);
next[key] = nextSlice;
changed = changed || nextSlice !== prevSlice; // did THIS slice change?
}
return changed ? next : state; // nothing changed → hand back the SAME object
};
}
function applyMiddleware(...middlewares) {
return function enhancer(createStore) {
return function (reducer, preloadedState) {
const store = createStore(reducer, preloadedState);
let dispatch = () => {
throw new Error('dispatching during middleware setup is not allowed');
};
// Each middleware gets { getState, dispatch }, where dispatch routes
// through the WHOLE chain — so a middleware can re-dispatch.
const api = {
getState: store.getState,
dispatch: (action) => dispatch(action),
};
const chain = middlewares.map((mw) => mw(api));
dispatch = compose(...chain)(store.dispatch);
return { ...store, dispatch };
};
};
}
const miniRedux = { createStore, combineReducers, applyMiddleware };
module.exports = { miniRedux };
The one shift in createStore is order: state = reducer(state, action) runs before the notify loop, so every listener that calls getState sees the new value. subscribe hands back a closure that flips a subscribed flag and filters the listener out, so unmounting is one call and calling it twice is safe. The final dispatch({ type: '@@redux/INIT' }) is the standard trick to make every reducer return its default state once, so the store is never undefined.
combineReducers is where the reference-equality lesson lives. It runs each slice reducer on its own piece of state and collects the results into next. The changed flag tracks whether any slice returned a different reference. If one did, it returns the new next object; if every slice returned the exact value it already held, it returns the original state untouched, so a consumer comparing references knows nothing changed and can skip its work.
applyMiddleware is an enhancer: a function that takes createStore and returns a souped-up one. It builds the real store, then replaces its dispatch with a chain. Each middleware is called with api to get an action => {} handler, and compose nests those handlers so the rightmost wraps the base store.dispatch first. The key detail is that api.dispatch points at the finished chain, not store.dispatch, so a middleware that re-dispatches sends the action back through the whole chain rather than skipping straight to the reducer.
Take a store built from two slices and dispatch one action:
const store = createStore(combineReducers({ count: counter, todos }));
store.dispatch({ type: 'ADD_TODO', text: 'milk' });
state is { count: 0, todos: [] }. Each slice reducer turned its undefined slice into a default, so changed was true and the combined reducer built that first object.ADD_TODO action reaches the combined reducer. It runs counter(0, action), which returns 0 again (the counter ignores ADD_TODO), and todos([], action), which returns a brand-new array ['milk'].count slice is referentially identical, but the todos slice is a new array, so changed is true and a new root { count: 0, todos: ['milk'] } is returned. Notice count was carried straight over by value.dispatch, state is now that new object, and only then does the notify loop run. Any subscriber that calls getState reads { count: 0, todos: ['milk'] }, never the pre-update value.{ type: 'INC' } next. Now counter(0, ...) returns 1 (new) and todos(['milk'], ...) returns the same array by reference. changed is true, so a new root is built, but its todos is the identical array from before, shared by reference. A memoized todo list sees an unchanged reference and skips re-rendering.state = reducer(...), every subscriber reads the previous state through getState and the UI shows stale data. Fix: compute the next state first, then notify.subscribe with no way to unsubscribe — pushing the listener and returning nothing leaks; an unmounted component keeps being called on every dispatch. Fix: return a function that removes exactly that listener.combineReducers allocating on every action — spreading into a fresh object unconditionally hands back a new reference even when nothing changed, so prev === next is never true and every consumer re-runs. Fix: track a changed flag per slice and return the original state when all slices are referentially equal.next with store.dispatch in middleware — next(action) passes the action down the rest of the chain toward the reducer; store.dispatch(action) sends it back to the top of the chain. Re-dispatching with next silently skips every middleware above you. Use store.dispatch to start over, next to continue.state.count++; return state keeps the same reference, so combineReducers decides the slice is unchanged and drops the update. Fix: return a new value for the slice you change.replaceReducer — expose a method that swaps the reducer at runtime so you can code-split and lazy-load feature reducers into the store.dispatch accept a function (dispatch, getState) => {} for async flows; it is about a dozen lines on top of what you have here.combineReducers also compares the number of keys and warns when the state carries a key no reducer owns, which catches typos in your slice names.Symbol.observable interop and libraries like React-Redux build useSelector on top of the subscribe primitive with an equality check; the subscribe hook here is where all of that attaches.Keep practising the same patterns with a nearby challenge.
No submissions yet
Share your approach and start the discussion.