React Patterns
Build a Redux Store From Scratch - Learn State Management by Implementing It
March 4, 2026
Understanding how Redux works under the hood makes you better at state management - whether you use Redux, Zustand, or plain React. This guide walks you through building a minimal Redux-style store from scratch: you'll implement createStore, wire up a reducer, and see a real use case with a todo app.
By the end you'll know what getState, dispatch, and subscribe do, why reducers must be pure, and when this pattern fits your app.
Why Build a Store From Scratch?
- Learn the contract: Redux (and many libraries) are built on a small set of ideas. Implementing them once makes every state library easier to reason about.
- No dependencies: A ~30-line store is enough for small apps or prototypes without adding Redux as a dependency.
- Debugging: When something goes wrong, you'll know exactly where state comes from and how it updates.
The Store API: Three Methods
A Redux-style store does three things:
| Method | Purpose |
|---|---|
getState() | Returns the current state. Read-only. |
dispatch(action) | Sends an action to the store. The reducer computes the next state; then every subscriber is notified. |
subscribe(listener) | Registers a callback that runs after every dispatch. Returns an unsubscribe function so you can remove the listener. |
State is updated in one place - the reducer - and everyone else reads or subscribes. No arbitrary mutations.
Implementing createStore
Below is a minimal createStore(reducer, initialState). It holds state in a closure and notifies listeners after each dispatch.
const createStore = (reducer, initialState) => {
let state = initialState;
let listeners = [];
const getState = () => state;
const dispatch = (action) => {
state = reducer(state, action);
listeners.forEach((listener) => listener());
};
const subscribe = (listener) => {
listeners.push(listener);
return () => {
listeners = listeners.filter((l) => l !== listener);
};
};
return { getState, dispatch, subscribe };
};What each part does:
getState- Returns the currentstatefrom the closure. Callers can read but not replace it.dispatch- Calls the reducer with the current state and the action, saves the result as the new state, then runs every subscribed listener so the UI (or other code) can react.subscribe- Pushes the listener ontolistenersand returns an unsubscribe function that removes that listener. Unsubscribing prevents leaks when components unmount.
The reducer is the single source of truth: given (state, action), it returns the next state. No side effects inside the reducer - keep it pure so updates are predictable and testable.
The Reducer: Pure Function That Computes Next State
A reducer has the shape (state, action) => newState. It never mutates the current state; it returns a new object (or the same reference if nothing changed).
Actions are plain objects with at least a type; often they include a payload (e.g. the todo text or id).
const initialState = { todos: [] };
const reducer = (state = initialState, action) => {
switch (action.type) {
case "ADD_TODO":
return {
...state,
todos: [
...state.todos,
{ id: crypto.randomUUID(), text: action.payload, completed: false },
],
};
case "DELETE_TODO":
return {
...state,
todos: state.todos.filter((todo) => todo.id !== action.payload),
};
case "TOGGLE_TODO":
return {
...state,
todos: state.todos.map((todo) =>
todo.id === action.payload
? { ...todo, completed: !todo.completed }
: todo,
),
};
default:
return state;
}
};ADD_TODO- Appends a new todo (with id andcompleted: false) to a newtodosarray.DELETE_TODO- Filters out the todo whose id matchesaction.payload.TOGGLE_TODO- Maps over todos and flipscompletedonly for the matching id.
Using spread and new arrays keeps the reducer pure and makes time-travel debugging and tests straightforward.
Real Use Case: Todo App With the Custom Store
You can plug this store into a React app: create it once, subscribe in a component to re-render on changes, and dispatch actions from event handlers.
1. Create the store and export it
// store.js
const store = createStore(reducer, initialState);
export default store;2. Use the store in a React component
"use client";
import { useEffect, useState } from "react";
import store from "./store";
function TodoList() {
const [state, setState] = useState(store.getState());
useEffect(() => {
const unsubscribe = store.subscribe(() => setState(store.getState()));
return unsubscribe;
}, []);
const addTodo = (text) => {
store.dispatch({ type: "ADD_TODO", payload: text });
};
const toggleTodo = (id) => {
store.dispatch({ type: "TOGGLE_TODO", payload: id });
};
const deleteTodo = (id) => {
store.dispatch({ type: "DELETE_TODO", payload: id });
};
return (
<div>
<form
onSubmit={(e) => {
e.preventDefault();
const input = e.currentTarget.elements.namedItem("todo");
if (input?.value) addTodo(input.value);
}}
>
<input name="todo" placeholder="What to do?" />
<button type="submit">Add</button>
</form>
<ul>
{state.todos.map((todo) => (
<li key={todo.id}>
<input
type="checkbox"
checked={todo.completed}
onChange={() => toggleTodo(todo.id)}
/>
<span
style={{
textDecoration: todo.completed ? "line-through" : "none",
}}
>
{todo.text}
</span>
<button type="button" onClick={() => deleteTodo(todo.id)}>
Delete
</button>
</li>
))}
</ul>
</div>
);
}useState(store.getState())- Initializes component state from the store.store.subscribe- After every dispatch, the listener runs and updates local state withstore.getState(), triggering a re-render. The effect returns the unsubscribe so the listener is removed on unmount.- Event handlers - Call
store.dispatchwith the appropriate action type and payload. The reducer updates the store; subscribers (including this component) then re-render.
This same pattern applies to other domains: shopping cart (add/remove/update quantity), theme or settings (toggle dark mode, set language), or notifications (add/dismiss). One store, one reducer, many components subscribing and dispatching.
When to Use This Pattern
- Learning: Building the store once clarifies how Redux and similar libraries work.
- Small apps or prototypes: When you need global state without adding a dependency, this minimal store is enough.
- Larger apps: Use Redux Toolkit (or similar) for devtools, middleware, and scaling. The concepts - reducer, actions, single store - stay the same.
Summary
- A Redux-style store exposes
getState,dispatch, andsubscribe. State lives in a closure; only the reducer changes it. - The reducer is a pure function
(state, action) => newState; avoid mutation and side effects. - Real use: Create the store once, subscribe in components to re-render on changes, and dispatch actions from UI events. The same idea works for todos, carts, or app settings.
- Implementing this once makes state management in any framework easier to understand and debug.
Related content
Compose Internals - Subcomponents Read From Context, Not Props
Compound components shouldn't receive state and callbacks through props. They read from a shared context so the same UI works with different state sources - local, global, or server.
Read moreComposition Over Configuration - Let Consumers Build, Not Configure
Stop piling boolean and config props onto one component. Let consumers compose the pieces they need - fewer props, clearer intent, and code that scales.
Read moreExplicit Component Variants - Name the Use Case, Drop the Booleans
One component with isThread, isEditing, isForwarding is hard to reason about. Create named variants - ThreadComposer, EditComposer - so each screen is explicit and self-documenting.
Read more