Skip to main content

Command Palette

Search for a command to run...

Building Your Own React State Management: A Deep Dive into Custom Stores

Published
8 min read
Building Your Own React State Management: A Deep Dive into Custom Stores

In the ever-evolving React ecosystem, state management remains one of the most discussed and reimagined aspects of frontend development. While popular libraries like Redux, Zustand, and Jotai offer robust solutions, there's significant value in understanding how to build your own state management system. This article explores the why and how of creating custom stores in React, empowering you with knowledge that goes beyond simply importing the next trending library.

Why Consider Building Your Own Store?

Before diving into implementation details, let's consider when creating your own state management solution makes sense:

  1. Educational value: Understanding the internals of state management helps you better utilize existing libraries

  2. Simplicity: When your needs are specific and well-defined, a custom solution can be less overhead than adapting a general-purpose library

  3. Integration requirements: Custom stores can be designed specifically for your unique API interactions or system requirements

  4. Performance optimization: When you need precise control over rendering behavior and state updates

  5. Project ownership: Building your own solution creates deeper team understanding and control over critical application infrastructure

As experienced React developer Kent C. Dodds often says, "The more you understand your tools, the more effectively you can use them." Building a custom store provides insights into state management that will serve you regardless of which libraries you ultimately choose.

The State of State Management in React

The React state management landscape has evolved considerably:

First Wave: Redux dominated with its predictable, centralized approach but introduced significant boilerplate.

Second Wave: Context API with useReducer offered native solutions but with performance limitations.

Current Wave: Libraries like Zustand, Jotai, and Recoil focus on atomic updates, composability, and developer experience.

Each approach represents different philosophies about how state should be structured, updated, and consumed. Your custom solution can incorporate the best aspects of these approaches while avoiding unnecessary complexities.

Anatomy of a Custom React Store

At its core, a React store needs to:

  1. Hold state

  2. Provide methods to update state

  3. Notify components when state changes

  4. Integrate with React's rendering cycle

Let's examine an implementation that achieves these goals using modern React patterns:

import { cloneDeep } from "lodash-es";
import { useSyncExternalStore } from "react";

type Watcher<T> = [(old: T, data: T) => boolean, (data: T) => void];

export function createStore<T>(initialState: T) {
  let state: T = cloneDeep(initialState);
  let listeners: (() => void)[] = [];
  let watchers: Watcher<T>[] = [];

  const subscribe = (listener: () => void) => {
    listeners.push(listener);
    return () => {
      listeners = listeners.filter((f) => f !== listener);
    };
  };

  const dispatchWatches = (newState: T) => {
    const old = state;
    state = newState;
    watchers.forEach(([check, cb]) => {
      if (check(old, newState)) {
        cb(newState);
      }
    });
  };

  const dispatch = () => {
    listeners.forEach((a) => {
      a();
    });
  };

  const getSnapshot = () => state;

  const set = (data: T) => {
    dispatchWatches(data);
    dispatch();
  };

  const update = (fn: (data: T) => T) => {
    dispatchWatches(fn(state));
    dispatch();
  };

  const reset = () => {
    dispatchWatches(cloneDeep(initialState));
    dispatch();
  };

  const watch = (check: Watcher<T>[0], cb: Watcher<T>[1]) => {
    watchers.push([check, cb]);
    return () => {
      watchers = watchers.filter((f) => f[0] !== check);
    };
  };

  return {
    subscribe,
    getSnapshot,
    set,
    update,
    reset,
    watch,
  };
}

export const useStore = <T>(store: ReturnType<typeof createStore<T>>) =>
  useSyncExternalStore(store.subscribe, store.getSnapshot, store.getSnapshot);

This implementation leverages React 18's useSyncExternalStore hook, which was specifically designed to connect external state management systems with React's rendering cycle.

Understanding the Key Components

Let's break down the essential elements of this custom store:

State Container

let state: T = cloneDeep(initialState);

The store maintains a single source of truth, creating a deep copy of the initial state to ensure immutability.

Subscription System

const subscribe = (listener: () => void) => {
  listeners.push(listener);
  return () => {
    listeners = listeners.filter((f) => f !== listener);
  };
};

Components can subscribe to state changes, and the function returns a cleanup method to unsubscribe when components unmount.

Update Mechanisms

const set = (data: T) => {
  dispatchWatches(data);
  dispatch();
};

const update = (fn: (data: T) => T) => {
  dispatchWatches(fn(state));
  dispatch();
};

The store provides two primary ways to update state:

  • set: Directly replace the entire state

  • update: Use a function to transform the current state into a new one

Conditional Watchers

const watch = (check: Watcher<T>[0], cb: Watcher<T>[1]) => {
  watchers.push([check, cb]);
  return () => {
    watchers = watchers.filter((f) => f[0] !== check);
  };
};

Watchers provide a powerful mechanism for reacting to specific state changes. Unlike regular listeners that fire on every state update, watchers only trigger when their condition function returns true.

React Integration

export const useStore = <T>(store: ReturnType<typeof createStore<T>>) =>
  useSyncExternalStore(store.subscribe, store.getSnapshot, store.getSnapshot);

This custom hook connects our store to React's rendering system, ensuring components re-render appropriately when state changes.

Putting It Into Practice: Building a Task Management Application

Let's create a simple task management application to demonstrate our custom store in action:

// Create our task store
const taskStore = createStore({
  tasks: [],
  filter: 'all'
});

// Component using the store
function TaskManager() {
  const { tasks, filter } = useStore(taskStore);
  const [newTask, setNewTask] = useState('');

  const filteredTasks = useMemo(() => {
    return tasks.filter(task => {
      if (filter === 'completed') return task.completed;
      if (filter === 'active') return !task.completed;
      return true;
    });
  }, [tasks, filter]);

  const addTask = () => {
    if (!newTask.trim()) return;

    taskStore.update(state => ({
      ...state,
      tasks: [...state.tasks, {
        id: Date.now(),
        text: newTask,
        completed: false
      }]
    }));

    setNewTask('');
  };

  const toggleTask = (id) => {
    taskStore.update(state => ({
      ...state,
      tasks: state.tasks.map(task => 
        task.id === id ? { ...task, completed: !task.completed } : task
      )
    }));
  };

  return (
    <div className="task-manager">
      {/* Implementation details */}
    </div>
  );
}

This example demonstrates how our custom store provides a clean, flexible state management solution without the overhead of external libraries.

Advanced Patterns and Techniques

As you become more comfortable with custom stores, consider these advanced patterns:

1. Store Composition

Break your application state into domain-specific stores that can work together:

const userStore = createStore({ /* user state */ });
const taskStore = createStore({ /* task state */ });
const uiStore = createStore({ /* UI state */ });

2. Middleware Implementation

Add middleware support to intercept and transform state updates:

const createStoreWithMiddleware = (initialState, middlewares = []) => {
  const store = createStore(initialState);
  const originalUpdate = store.update;

  store.update = (fn) => {
    let result = fn;

    // Apply middlewares in reverse to compose functions
    middlewares.slice().reverse().forEach(middleware => {
      const next = result;
      result = (state) => middleware(state, next);
    });

    originalUpdate(result);
  };

  return store;
};

3. Selectors for Performance

Implement selectors to derive data from your store without unnecessary re-renders:

const createSelector = (store, selectorFn) => {
  let lastState = null;
  let lastResult = null;

  return () => {
    const currentState = store.getSnapshot();

    if (currentState !== lastState) {
      lastResult = selectorFn(currentState);
      lastState = currentState;
    }

    return lastResult;
  };
};

Testing Your Custom Store

Thorough testing ensures your store behaves as expected:

describe('Custom Store', () => {
  test('should initialize with correct state', () => {
    const initialState = { count: 0 };
    const store = createStore(initialState);

    expect(store.getSnapshot()).toEqual(initialState);
  });

  test('should update state correctly', () => {
    const store = createStore({ count: 0 });

    store.update(state => ({ count: state.count + 1 }));

    expect(store.getSnapshot()).toEqual({ count: 1 });
  });

  test('watchers should only trigger on condition', () => {
    const store = createStore({ count: 0, name: 'test' });
    const mockCallback = jest.fn();

    store.watch(
      (old, current) => old.count !== current.count,
      mockCallback
    );

    store.update(state => ({ ...state, name: 'updated' }));
    expect(mockCallback).not.toHaveBeenCalled();

    store.update(state => ({ ...state, count: 1 }));
    expect(mockCallback).toHaveBeenCalledTimes(1);
  });
});

Real-World Considerations

When implementing custom stores in production applications, consider these practical aspects:

Performance Optimization

For large state objects, consider:

  1. Implementing shallow copying instead of deep cloning

  2. Using structural sharing techniques similar to Immer

  3. Adding memoization for derived state

DevTools Integration

For debugging purposes, consider adding Redux DevTools support:

// Simplified implementation
if (typeof window !== 'undefined' && window.__REDUX_DEVTOOLS_EXTENSION__) {
  const devTools = window.__REDUX_DEVTOOLS_EXTENSION__.connect({
    name: 'Custom Store'
  });

  devTools.init(state);

  const originalUpdate = store.update;
  store.update = (fn) => {
    originalUpdate((state) => {
      const newState = fn(state);
      devTools.send('UPDATE', newState);
      return newState;
    });
  };
}

Error Handling

Add robust error handling to prevent store corruption:

const update = (fn: (data: T) => T) => {
  try {
    const newState = fn(state);
    dispatchWatches(newState);
    dispatch();
  } catch (error) {
    console.error("Error updating store:", error);
    // Potentially roll back to previous state or implement recovery strategy
  }
};

When to Use Custom Stores vs. Established Libraries

While building your own store is valuable, existing libraries have their place:

Use a custom store when:

  • Your state management needs are specific and well-defined

  • You want minimal dependencies

  • Performance optimization is critical

  • You need complete control over the implementation

Use established libraries when:

  • You need battle-tested solutions for complex state problems

  • Your team is already familiar with them

  • You require extensive ecosystem support (middleware, devtools, etc.)

  • Time-to-market is a priority over custom implementation

Conclusion: The Power of Understanding

Building your own state management solution provides invaluable insights into how state works in React applications. Even if you ultimately choose to use established libraries, the knowledge gained from creating your own store enhances your ability to debug issues, optimize performance, and make informed architecture decisions.

As React continues to evolve, the fundamental principles of state management remain constant: maintain a single source of truth, provide predictable update patterns, and efficiently notify components of changes. By understanding these principles at their core, you'll be well-equipped to adapt to whatever new patterns and libraries emerge in the future.

Remember, the goal isn't necessarily to replace existing libraries but to deepen your understanding of the problems they solve. As the saying goes, "Give someone a library, and they'll build an application; teach someone to build a library, and they'll understand a thousand applications."

Whether you use this custom store implementation in production or simply learn from its patterns, the knowledge gained will make you a more effective React developer.

Comments (24)

Join the discussion
L

Really cool deep dive! I’ve found that rolling my own simplified store with useSyncExternalStore was a game-changer for understanding when you actually need a library versus when a custom hook suffices—especially for scoped UI state.

M
Mm Cc1mo ago

Great post! I once built a tiny Zustand-like store from scratch for a side project, and it totally changed how I think about subscriptions and batching in React. There’s nothing like implementing useSyncExternalStore yourself to really appreciate what these libraries handle under the hood.

W
Wily Ktpm1mo ago

Great read! I recently took a similar deep dive while migrating a legacy app—what started as curiosity turned into a huge aha moment when I realized how much simpler my custom hook + context solution was compared to the third-party library we’d been wrestling with. Definitely makes you appreciate the trade-offs in the ecosystem.

S

Great deep dive! As a complement, I’d suggest wrapping your custom store in a React.memo on the consumer side or using useSyncExternalStore for optimal re-render performance, especially in large component trees. This ensures you're not reinventing the wheel on subscription logic while still owning the state layer.

M
Mm Cc1mo ago

Great deep dive! One best practice I'd add is to always wrap your core state logic in a useRef alongside useSyncExternalStore for concurrent-mode safety—it prevents stale closures and unnecessary re-renders when subscriptions fire in rapid succession.

F

Great deep dive! One tip for anyone rolling their own solution: consider baking in a simple DevTools middleware from the start, even if it’s just logging state diffs—it’ll save hours of debugging when your custom store gets complex.

F

Great read! I’ve found that building a custom store really demystifies closures and the observer pattern—after that, you start seeing every third-party state lib as just a polished version of the same core ideas. It’s also a great reminder that you don’t always need a heavy dependency for simple app state.

M
Mm Cc2mo ago

As someone who's wrestled with prop drilling in a mid-sized app, this deep dive into the underlying mechanics is exactly what I needed. Understanding the "why" behind the abstractions makes choosing (or building) a state solution feel less like following trends. Great read.

J

As someone who's gone down this path, building a custom solution really cemented my understanding of the render cycle. Your point about the trade-offs in complexity versus optimization for a specific app's needs is spot-on—it's a decision every senior dev should make intentionally.

L

Great post! I especially appreciated the practical walkthrough of building a minimal store—it really demystifies the core concepts behind the libraries we use daily. This deep dive into the "why" is incredibly valuable for making informed architectural choices.