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

GASTON CHEGASTON CHE
Mar 17, 2025·
8 min read

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.

17
Subscribe to my newsletter

Read articles from GASTON CHE directly inside your inbox. Subscribe to the newsletter, and don't miss out.

Written by

GASTON CHE
GASTON CHE