VD
react2026-09-0516 min

Build Your Own Redux: A Step-by-Step Guide to State Stores

Demystify Redux by building your own store step-by-step. Learn how closures, reducers, and subscriptions work, explore alternative store ideas, and connect it to React.

Build Your Own Redux: A Step-by-Step Guide to State Stores

Redux has a reputation for being complicated. People talk about actions, dispatchers, reducers, middleware, and slices, and it can feel like a mountain of boilerplate.

But here's the secret: the core idea behind Redux fits in fewer than 40 lines of plain JavaScript.

In this tutorial, we'll build our own Redux-style store from scratch, one tiny step at a time. No complex jargon, no massive type definitions upfront—just clean, readable code.

Along the way, we'll explore:

  1. How a store actually holds and changes data.
  2. The pros and cons of the classic Redux pattern.
  3. Alternative ideas for building stores (like Zustand, MobX/Valtio, and Jotai).
  4. How to connect our custom store to React components cleanly.

Step 1: The Simplest Store (Just a Closure)

At its most basic level, what is a store? It's a box that holds some state in memory and lets you read it.

We can create one with a simple JavaScript function and a closure:

function createStore(initialState) {
  let state = initialState;

  function getState() {
    return state;
  }

  return {
    getState,
  };
}

Let's try using it:

const store = createStore({ count: 0 });
console.log(store.getState()); // { count: 0 }

That was easy! We've got a private state variable that can't be messed with from the outside, and a getState() function to peek at it.

Now comes the real question: How do we change the state?


Step 2: Changing State (Why Do We Need Reducers?)

You might be tempted to just add a setState function:

// Why don't we just do this?
function setState(newState) {
  state = newState;
}

While that works for small scripts, in large apps it quickly becomes chaotic. Any component anywhere can overwrite the entire state with whatever it wants. When a bug happens, you have no idea who changed the state, when they changed it, or why.

Redux solves this with two simple rules:

  1. Actions: You cannot change state directly. You can only dispatch an action—a plain JavaScript object that describes what happened (e.g., { type: "INCREMENT" }).
  2. Reducers: A reducer is a function that takes the current state and the action, and decides what the next state should be.

Here is what a reducer looks like:

function counterReducer(state = { count: 0 }, action) {
  switch (action.type) {
    case "INCREMENT":
      return { count: state.count + 1 };
    case "DECREMENT":
      return { count: state.count - 1 };
    case "RESET":
      return { count: 0 };
    default:
      return state;
  }
}

Notice that the reducer is a pure function:

  • It doesn't modify the existing state directly (state.count++ is forbidden).
  • It returns a brand-new object.
  • Given the same input, it always returns the exact same output.

Now let's add dispatch to our createStore. We'll also fire a special @@INIT action at the end—this lets the reducer set up its own default state if you don't pass an initialState:

function createStore(reducer, initialState) {
  let state = initialState;

  function getState() {
    return state;
  }

  function dispatch(action) {
    // Run the reducer to calculate the new state
    state = reducer(state, action);
  }

  // Initialize the state by dispatching a dummy action
  dispatch({ type: "@@INIT" });

  return {
    getState,
    dispatch,
  };
}

Let's test our new store:

const store = createStore(counterReducer, { count: 0 });

console.log(store.getState()); // { count: 0 }

store.dispatch({ type: "INCREMENT" });
console.log(store.getState()); // { count: 1 }

store.dispatch({ type: "DECREMENT" });
console.log(store.getState()); // { count: 0 }

Every state change now flows through a single predictable door.


Step 3: Notifying the Outside World (Pub-Sub)

Our store can hold state and update state. But right now, outside components have no idea when something changes unless they repeatedly call getState().

We need a way for components to say: "Hey, call me whenever the state changes so I can update my screen."

This is known as the Pub-Sub (Publisher-Subscriber) pattern.

We only need three things:

  1. A list to keep track of subscriber functions.
  2. A subscribe function to add a new listener.
  3. A way to notify every listener inside dispatch.

Here's the complete store:

function createStore(reducer, initialState) {
  let state = initialState;
  let listeners = [];

  function getState() {
    return state;
  }

  function subscribe(listener) {
    listeners = [...listeners, listener];

    // Return an unsubscribe function
    return function unsubscribe() {
      listeners = listeners.filter((l) => l !== listener);
    };
  }

  function dispatch(action) {
    state = reducer(state, action);

    // Snapshot the listeners before notifying
    for (const listener of listeners) {
      listener();
    }
  }

  dispatch({ type: "@@INIT" });

  return {
    getState,
    dispatch,
    subscribe,
  };
}

Let's see it in action:

const store = createStore(counterReducer, { count: 0 });

// Subscribe to changes
const unsubscribe = store.subscribe(() => {
  console.log("State changed:", store.getState());
});

store.dispatch({ type: "INCREMENT" }); // Logs: State changed: { count: 1 }
store.dispatch({ type: "INCREMENT" }); // Logs: State changed: { count: 2 }

// Unsubscribe when we are done
unsubscribe();
store.dispatch({ type: "INCREMENT" }); // (Nothing logged)
Key insight: In fewer than 40 lines of JavaScript, we've built a functional Redux store! It holds state, forces unidirectional updates through actions and reducers, and notifies subscribers.

Pros and Cons of This Approach

Before we look at alternatives, let's look honestly at the trade-offs of this classic Redux pattern.

The Good

  • Completely Predictable: Because state changes only through pure functions, you never have to guess how your app arrived in a specific state.
  • Trivial to Test: Testing business logic requires zero mocks. Just pass (state, action) into your reducer and check the output.
  • Time-Travel & Replay: Because every change is represented as an action object, you can record all user actions and replay them to reproduce any bug.

The Bad

  • Too Much Boilerplate: You have to write action types, action creators, and a switch statement even for simple updates.
  • Manual Immutability: You must constantly remember to spread objects (...state, user: { ...state.user }). A single accidental mutation breaks re-renders.
  • Coarse Subscriptions: Every time anything in the store changes, every subscriber gets called. If you have 200 components subscribed, all 200 run on every click unless you filter them.

This brings us to a fun question: Could we build a store differently?


Exploring Alternative Store Ideas

Over the years, the JavaScript community explored different ways to build a store. Let's look at three popular alternatives and see how their code and philosophy compare.

Idea 1: Store Methods Instead of Reducers (The Zustand Approach)

What if we got rid of action objects and switch statements altogether?

Instead of dispatching an action and writing a separate reducer, what if the store kept its update functions directly inside the state?

function createSimpleStore(createState) {
  let state;
  const listeners = new Set();

  function getState() {
    return state;
  }

  function setState(partial) {
    // Compute next state (supports both objects and updater functions)
    const nextState = typeof partial === "function" ? partial(state) : partial;
    state = { ...state, ...nextState };
    listeners.forEach((listener) => listener());
  }

  function subscribe(listener) {
    listeners.add(listener);
    return () => listeners.delete(listener);
  }

  // Initialize the store by passing setState and getState to the user's config
  state = createState(setState, getState);

  return { getState, setState, subscribe };
}

Now look how clean defining a store becomes:

const useCounterStore = createSimpleStore((set) => ({
  count: 0,
  increment: () => set((state) => ({ count: state.count + 1 })),
  decrement: () => set((state) => ({ count: state.count - 1 })),
}));

// Using it:
console.log(useCounterStore.getState().count); // 0
useCounterStore.getState().increment();
console.log(useCounterStore.getState().count); // 1
  • Pros: Almost zero boilerplate. No actions, no switch statements, great TypeScript auto-complete out of the box.
  • Cons: Less formal separation between data and actions. Harder to log action history unless you add middleware.

Idea 2: Proxy-Based Direct Mutations (The MobX / Valtio Approach)

What if you didn't have to call set or dispatch at all? What if you could just write:

state.count++;

And your UI updated automatically?

You can do this using modern JavaScript Proxy. A proxy lets you intercept property reads and writes:

function createProxyStore(initialObject) {
  const listeners = new Set();

  const proxy = new Proxy(initialObject, {
    set(target, property, value) {
      target[property] = value;
      // Notify listeners whenever any property is modified!
      listeners.forEach((listener) => listener());
      return true;
    },
  });

  return {
    state: proxy,
    subscribe(listener) {
      listeners.add(listener);
      return () => listeners.delete(listener);
    },
  };
}

Using it feels like ordinary JavaScript:

const store = createProxyStore({ count: 0 });

store.subscribe(() => {
  console.log("Count changed to:", store.state.count);
});

store.state.count = 5; // Logs: Count changed to: 5
  • Pros: Feels like natural JavaScript. No manual immutable copying (...state).
  • Cons: Can feel like "magic." It's easier to accidentally mutate state in places you didn't intend.

Idea 3: Atomic State (The Jotai / Recoil Approach)

What if we didn't have one giant store object at all?

In an atomic model, your state is split into tiny, independent pieces called atoms. Each atom holds its own little piece of state, and components only subscribe to the specific atoms they care about.

Here is how you can implement an atomic store in about 30 lines of JavaScript:

// 1. An atom is just a unique definition object holding a default value
function atom(initialValue) {
  return { init: initialValue };
}

// 2. The store holds a map of atom references to their current values and listeners
function createAtomicStore() {
  const atomValues = new Map();
  const atomListeners = new Map();

  function get(anAtom) {
    if (!atomValues.has(anAtom)) {
      atomValues.set(anAtom, anAtom.init);
    }
    return atomValues.get(anAtom);
  }

  function set(anAtom, update) {
    const prev = get(anAtom);
    const next = typeof update === "function" ? update(prev) : update;
    atomValues.set(anAtom, next);

    // Notify ONLY the listeners for this specific atom!
    const listeners = atomListeners.get(anAtom);
    if (listeners) {
      listeners.forEach((listener) => listener());
    }
  }

  function subscribe(anAtom, listener) {
    if (!atomListeners.has(anAtom)) {
      atomListeners.set(anAtom, new Set());
    }
    atomListeners.get(anAtom).add(listener);

    return function unsubscribe() {
      atomListeners.get(anAtom).delete(listener);
    };
  }

  return { get, set, subscribe };
}

Now let's see how this solves the notification problem:

const countAtom = atom(0);
const nameAtom = atom("Alice");

const store = createAtomicStore();

// Component A subscribes ONLY to countAtom
store.subscribe(countAtom, () => {
  console.log("Count changed to:", store.get(countAtom));
});

// Component B subscribes ONLY to nameAtom
store.subscribe(nameAtom, () => {
  console.log("Name changed to:", store.get(nameAtom));
});

store.set(countAtom, 1);
// Logs: "Count changed to: 1"
// Notice: The nameAtom subscriber NEVER ran!

store.set(nameAtom, "Bob");
// Logs: "Name changed to: Bob"
// Notice: The countAtom subscriber NEVER ran!
  • Pros: Truly surgical re-renders. Changing one atom never wakes up components listening to other atoms. Great for canvas apps, spreadsheet cells, and deeply dynamic UIs.
  • Cons: Harder to serialize the "whole app state" at once (for saving to disk, SSR hydration, or action replay).

Comparing the Ideas at a Glance

ApproachTypical LibraryHow You Change StateBest When...
Reducer StoreRedux / RTKDispatch action objects through pure reducersYou need strict rules, audit trails, and complex business workflows
Direct Action StoreZustandCall action methods directly on the storeYou want simple, fast, minimal boilerplate for everyday React apps
Proxy StoreValtio / MobXMutate properties directly (state.x = 1)You have heavily interactive UI (dashboards, games, canvases)
Atomic StoreJotai / RecoilUpdate individual atoms independentlyYou have lots of decentralized, loosely coupled state pieces

Interactive Demo: See the Notification Differences

To truly feel the difference between these paradigms, try the interactive demo below. Switch between the paradigms, click the buttons, and watch how the re-render counters and flash badges behave:

Interactive Demo

Store Notification Playground

<CounterComponent />Renders: 1

0

Subscribed to: count

<UserProfile />Renders: 1

Alice

Subscribed to: userName

Notice: In Classic Redux without selectors, clicking "Toggle User" forces the Counter to re-render even though its count never changed! All listeners run on every action.

Live Store Event Log
Store initialized with { count: 0, userName: 'Alice' }

Step 4: Connecting Our Store to React

Now that we have our core store, how do we use it in React?

The Natural First Try: useState + useEffect

The most intuitive way is to create a custom hook using basic React primitives:

import { useState, useEffect } from "react";

function useStore(store) {
  const [state, setState] = useState(() => store.getState());

  useEffect(() => {
    // Whenever the store notifies us, update local component state
    const unsubscribe = store.subscribe(() => {
      setState(store.getState());
    });
    return unsubscribe;
  }, [store]);

  return state;
}

Now we can use it in a React component:

function Counter({ store }) {
  const state = useStore(store);

  return (
    <div>
      <p>Count: {state.count}</p>
      <button onClick={() => store.dispatch({ type: "INCREMENT" })}>
        +1
      </button>
    </div>
  );
}

This works! But there is a serious underlying problem in modern React applications.

What is "Tearing" in React 18?

In React 18, React introduced Concurrent Rendering. React is no longer locked into rendering a component tree from start to finish in one synchronous, uninterruptible block. Instead, React can start rendering a screen, pause in the middle to handle an urgent user keystroke, and resume rendering later.

If an external store updates while React is paused mid-render:

  1. Component A (rendered before the pause) reads count = 1.
  2. React pauses to let the browser handle a user click.
  3. The click dispatches an action to the store: count becomes 2.
  4. React resumes rendering and renders Component B. Component B reads count = 2.
  5. When the screen paints, Component A shows 1 and Component B shows 2.

This visual inconsistency is called tearing—the UI tore into two mismatched representations of the same store!

useEffect cannot fix this because effects run after the browser has already painted the torn UI to the screen.

Interactive Demo: Experience Tearing

Run the demo below to see the difference between a naive useEffect subscription and useSyncExternalStore:

Interactive Demo

Concurrent Tearing Visualizer

Simulated Browser Screen Paint
<HeaderComponent /> (Rendered at T₀)Count: 1
⚡ External store dispatches: Count becomes 1
<FooterComponent /> (Rendered at T₁ after pause)Count: 1

The Modern Solution: Deep Dive into useSyncExternalStore

To fix tearing once and for all, React 18 introduced the official hook: useSyncExternalStore.

It has the following signature:

const snapshot = useSyncExternalStore(
  subscribe,
  getSnapshot,
  getServerSnapshot? // optional, for Server-Side Rendering
);

Let's look at each argument and what it does:

  1. subscribe ((callback) => unsubscribe): A function that registers a listener with your store. React passes its own internal listener callback. Whenever your store calls this listener, React schedules a re-render. Crucially, your subscribe function must return an unsubscribe cleanup function.
  2. getSnapshot (() => snapshot): A function that synchronously returns the current state of the store for this component.
  3. getServerSnapshot (() => snapshot): Optional, but required if you use Server-Side Rendering (SSR) or frameworks like Next.js. It returns the snapshot during server rendering and initial client hydration, ensuring the server HTML and client hydration match perfectly without hydration mismatch warnings.

Here is our complete, production-ready hook:

import { useSyncExternalStore } from "react";

function useStore(store) {
  return useSyncExternalStore(
    store.subscribe,
    store.getState,
    store.getState // getServerSnapshot for SSR
  );
}

How useSyncExternalStore Prevents Tearing Under the Hood

When React renders a component using useSyncExternalStore:

  1. Synchronous Snapshot Read: During the render phase, React calls getSnapshot() and remembers the exact reference returned.
  2. Interruption Detection: If an external event mutates the store while React is paused during a concurrent render pass, React calls getSnapshot() again before committing to the screen.
  3. Automatic Restart: If React notices that getSnapshot() returned a different value than what was read earlier, React immediately throws away the inconsistent render tree and restarts from the top synchronously.
Key insight: By checking snapshots synchronously at the boundaries of the render phase, useSyncExternalStore guarantees that every component on your screen is always rendered against the exact same point-in-time snapshot.

The Golden Rule of getSnapshot: Referential Stability

There is one critical rule you must know when using useSyncExternalStore: getSnapshot must return a cached or referentially stable value.

React compares the value returned by getSnapshot() using Object.is(). If getSnapshot() returns a newly created object every time it is called, React thinks your store changed on every single micro-step:

// ❌ DANGEROUS: Returns a new object reference every time!
function useBadStore(store) {
  return useSyncExternalStore(
    store.subscribe,
    () => ({ count: store.getState().count }) // New object every call!
  );
}

If you do this, React will enter an infinite re-render loop and throw: Maximum update depth exceeded.

Always make sure getSnapshot returns either:

  • The raw store state object (which only changes reference when a dispatch occurs).
  • A primitive value (number, string, boolean).
  • Or a memoized slice.

Step 5: Adding Selectors (Preventing Useless Re-renders)

Right now, our useStore hook returns the entire state. If store holds { count: 0, userName: "Alice" } and userName changes, our Counter component will re-render even though it only cares about count!

We can solve this by letting components pass a selector function. But we need to be careful—remember the Golden Rule from the previous section? If our selector returns a new object every time, Object.is will see a new reference and trigger infinite re-renders.

The trick is to cache the previous selected value and return the cached version if nothing actually changed:

import { useSyncExternalStore, useCallback, useRef } from "react";

function useStore(store, selector = (state) => state) {
  const prevRef = useRef(undefined);

  const getSnapshot = useCallback(() => {
    const nextSelected = selector(store.getState());

    // If the selected value hasn't changed, return the cached reference
    if (prevRef.current !== undefined && Object.is(prevRef.current, nextSelected)) {
      return prevRef.current;
    }

    prevRef.current = nextSelected;
    return nextSelected;
  }, [store, selector]);

  return useSyncExternalStore(
    store.subscribe,
    getSnapshot,
    getSnapshot
  );
}

Now our component only subscribes to count:

function Counter({ store }) {
  // Only re-renders if state.count changes!
  const count = useStore(store, (state) => state.count);

  return (
    <button onClick={() => store.dispatch({ type: "INCREMENT" })}>
      Count is: {count}
    </button>
  );
}

Because state.count is a primitive number, Object.is(1, 1) returns true. If userName changes in the store, state.count remains 1, and React skips re-rendering Counter entirely!


Bonus: Adding Middleware (How Logging Fits In)

What if you want to log every action, or send analytics whenever an action happens?

In Redux, a middleware is just a function that wraps around dispatch.

Here's how you can write a simple logger:

function addLogging(store) {
  const rawDispatch = store.dispatch;

  store.dispatch = function (action) {
    console.log("Dispatching:", action.type);
    console.log("Before:", store.getState());

    // Call the original dispatch
    rawDispatch(action);

    console.log("After:", store.getState());
  };
}

Let's test it:

const store = createStore(counterReducer, { count: 0 });
addLogging(store);

store.dispatch({ type: "INCREMENT" });
// Console logs:
// Dispatching: INCREMENT
// Before: { count: 0 }
// After: { count: 1 }

You intercepted the action, logged what you needed, and passed it along. Real Redux middleware uses functional chaining so you can stack dozens of middlewares together, but the core idea is identical: wrapping dispatch.


Interview Questions on Store Architecture

Interview Question: Why does Redux insist that state changes happen through pure reducer functions?

Answer: Pure reducers guarantee that given the same state and action, the resulting output is 100% deterministic. This makes unit testing effortless, prevents unexpected side effects, and enables developer tools like time-travel debugging and action replay.

Interview Question: Why did React 18 introduce useSyncExternalStore instead of having developers use useEffect?

Answer: In React 18's Concurrent Mode, React can pause and resume rendering across multiple frames. If an external store updates while React is paused, components rendered before and after the pause can see different values ("tearing"). useSyncExternalStore synchronizes with React's scheduler to detect store mutations during render passes and force a consistent update.


Summary and Next Steps

We started with a blank page and built a complete Redux-style store in basic JavaScript:

  1. State Storage: A simple closure (let state).
  2. Reading State: getState() returns the current value.
  3. Changing State: dispatch(action) runs a pure reducer (state, action) => newState.
  4. Subscriptions: An array of listener callbacks that fire on every update.
  5. React Integration: useSyncExternalStore connects the store to React without tearing.

The next time you see a state management library, remember: underneath all the documentation and helper utilities, it's just closures, callbacks, and functions passing data around.


For a deeper look at how React Context compares to external stores and when each is the right choice, check out React Context Deep Dive and State Management Architecture.

VD

Vikram Dokkupalle

Frontend Engineer & UI/UX Enthusiast. Passionate about React, performance, and clean design.

More from react

View all posts
2026-01-2520 min

Frontend Security in React: Vulnerabilities, Protections & Best Practices

A comprehensive guide to frontend security covering XSS, CSRF, injection attacks, and CSP. Learn how React protects you automatically and where you still need to be vigilant.

2026-01-2225 min

Understanding React Server Components: Architecture, Patterns & Best Practices

A deep dive into React Server Components covering the RSC protocol, streaming architecture, server actions, and best practices for building performant Next.js applications.

2026-01-1315 min

React Context Deep Dive: Avoiding Re-renders and Advanced Patterns

Master React Context with this deep dive into how it works internally, why it causes re-renders, and proven patterns to optimize performance in production applications.