Skip to main content
Arthur Ha

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:

MethodPurpose
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.

TSX
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 current state from 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 onto listeners and 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).

TSX
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 and completed: false) to a new todos array.
  • DELETE_TODO - Filters out the todo whose id matches action.payload.
  • TOGGLE_TODO - Maps over todos and flips completed only 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

TSX
// store.js
const store = createStore(reducer, initialState);
export default store;

2. Use the store in a React component

TSX
"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 with store.getState(), triggering a re-render. The effect returns the unsubscribe so the listener is removed on unmount.
  • Event handlers - Call store.dispatch with 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, and subscribe. 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.