⚠ 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:
createStore
: A function to create a store that supports a single reducer.Provider
: A component to provide the store to its children.useDispatch
: A hook to dispatch actions to the store.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:
- Subscribe (and unsubscribe) to changes.
- Dispatch actions.
- 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.
Try updating the count
or message
, and you will see that the other component is not re-rendered:
- If you update
count
→ only theCounter
component is re-rendered. - If you update
message
→ only theMessage
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!