7 min read
Create Your Own Redux-like State Manager

Disclaimer

The code presented in this blog is intentionally simplified for educational purposes. It is neither optimized nor suitable for production use. Please use it as a learning tool to understand the concepts.

I have always been curious about how state management libraries work under the hood. One day, while exploring various state management libraries for React (Redux, Jotai, etc.), I decided to create my own Redux for fun and learning purposes.

This blog will walk you through the process of building a simple state management library inspired by Redux.

Our library will include the following features:

  1. createStore: A function to create a store that supports a single reducer.
  2. Provider: A component to provide the store to its children.
  3. useDispatch: A hook to dispatch actions to the store.
  4. useSelector: A hook to access data from the store.

Now, let’s implement it.


Implementing createStore

The createStore function is used to create a store, and it accepts initialState and reducer as arguments.

The reducer should treat state values as immutable, and I will explain why? when we implement useSelector.

With a store, you can:

  1. Subscribe (and unsubscribe) to changes.
  2. Dispatch actions.
  3. Get the current state snapshot.

Example:

const initialState = { count: 0 };

const reducer = (state, action) => {
  switch (action.type) {
    case "increment":
      return { count: state.count + 1 };
    case "decrement":
      return { count: state.count - 1 };
    default:
      return state;
  }
};

const store = createStore(initialState, reducer);

const unsubscribe = store.subscribe(() => {
  console.log(`count = ${store.getState().count}`);
});

store.dispatch({ type: "increment" }); // count = 1
store.dispatch({ type: "decrement" }); // count = 0
store.dispatch({ type: "decrement" }); // count = -1
store.dispatch({ type: "decrement" }); // count = -2

Implementation:

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

  function subscribe(listener) {
    listeners.push(listener);
    return () => {
      listeners = listeners.filter((l) => l !== listener);
    };
  }

  function getState() {
    return state;
  }

  function dispatch(action) {
    state = reducer(state, action);
    listeners.forEach((l) => l());
  }

  return {
    subscribe,
    getState,
    dispatch,
  };
}

Implementing Provider

I’ve simply used React’s createContext API and useContext hook.

import { useContext, createContext } from "react";

const StoreContext = createContext(null);

const Provider = (props) => {
  const { children, store } = props;
  return (
    <StoreContext.Provider value={store}>{children}</StoreContext.Provider>
  );
};

const useStoreContext = () => {
  const store = useContext(StoreContext);
  if (!store) throw new Error("Please provide the store using the Provider");
  return store;
};

Implementing useDispatch

I am simply exposing the store’s dispatch method using this hook.

const useDispatch = () => {
  const store = useStoreContext();
  return store.dispatch;
};

Implementing useSelector

I will show two implementations: one with useSyncExternalStore and another without it.

The best and correct way to implement this hook is to use React’s useSyncExternalStore hook.

1. With useSyncExternalStore

To understand how useSyncExternalStore works, please read the docs.

const useSelector = (selector) => {
  const store = useStoreContext();

  const value = useSyncExternalStore(store.subscribe, () =>
    selector(store.getState())
  );

  return value;
};

2. Without useSyncExternalStore

This is interesting because here we manually subscribe to the store and upon update re-render the component with new data.

To force the component to render, we use useReducer to toggle a boolean state, without using the actual state value.

const [, forceRender] = useReducer((x) => !x, true);

Let’s look at implementation.

const useSelector = (selector) => {
  const store = useStoreContext();
  const [, forceRender] = useReducer((x) => !x, true);
  const value = useRef(selector(store.getState()));

  useEffect(() => {
    const onUpdate = () => {
      const newValue = selector(store.getState());

      // forceRender if the newValue is not same as (old) value
      if (newValue !== value.current) {
        value.current = newValue;
        forceRender();
      }
    };

    const unsubscribe = store.subscribe(onUpdate);

    return unsubscribe;
  }, [store, selector]);

  return value.current;
};

Why should the reducer treat state values as immutable?

In the code above, the logic that re-renders a component relies on a simple equality check. In the case of objects, it compares their references.

If you mutate the state directly, the reference stays the same even though the contents have changed. As a result, the equality check will not detect any change, and the component will not re-render as expected.

By treating state as immutable and always returning a new object when making changes, you ensure that the reference changes. This allows the equality check to work correctly, triggering a re-render when the value has changed.

This is a fundamental principle in Redux and similar state management libraries, which makes state updates predictable.


Using Our Global State Library

We have a global state with two properties: count and message.
The App component contains Counter and Message components, which read and mutate the global state.

// store.js
const initialState = { count: 0, message: "Hello" };

const reducer = (state, action) => {
  switch (action.type) {
    case "increment":
      return { ...state, count: state.count + 1 };
    case "decrement":
      return { ...state, count: state.count - 1 };
    case "update-message":
      return { ...state, message: action.payload };
    default:
      return state;
  }
};

export const store = createStore(initialState, reducer);
// Message.jsx
export function Message() {
  const message = useSelector((s) => s.message);
  const dispatch = useDispatch();

  console.log("🟣 Message rendered...");

  return (
    <div>
      <input
        value={message}
        onChange={(e) =>
          dispatch({ type: "update-message", payload: e.target.value })
        }
      />
      <div>Message: {message}</div>
    </div>
  );
}
// Counter.jsx
export const Counter = () => {
  const count = useSelector((s) => s.count);
  const dispatch = useDispatch();

  console.log("🔵 Counter rendered...");

  return (
    <div>
      <div>
        <button
          onClick={() => {
            dispatch({ type: "increment" });
          }}
        >
          ++
        </button>
        <button
          onClick={() => {
            dispatch({ type: "decrement" });
          }}
        >
          --
        </button>
      </div>
      <div>Count: {count}</div>
    </div>
  );
};
// App.jsx
export const App = () => {
  return (
    <Provider store={store}>
      <Counter />
      <Message />
    </Provider>
  );
};

Observing Component Re-renders

Both components log a message to the console when they are rendered. You can observe this in the developer tools.

App component
Counter component
Count: 0
Message component
Message: Hello

Try updating the count or message, and you will see that the other component is not re-rendered:

  • If you update count → only the Counter component is re-rendered.
  • If you update message → only the Message component is re-rendered.

This demonstrates the efficiency of our state management library.


Conclusion

Creating your own state management library is a great way to understand how libraries like Redux work under the hood.

While this implementation is not production-ready, it serves as a learning tool to explore concepts like state, actions, reducers, and subscriptions.

I hope this blog inspires you to dive deeper into the world of state management!

Happy learning!