Building react state manager

Yazalde Filimone

Nov 6, 2023

We’re going to build a performant state manager that minimizes unnecessary re-renders and efficiently handles updates. I’ve tried to sketch the concept below—it’s my first attempt, so it might be a bit rough 😅.

Our goal is to pass state to any node in our React component tree and update it efficiently. Only the components that use the state will re-render, while others remain unchanged.

  1. We initialize a counter with 0 on the right node and pass the same counter to the left node.
  2. We update the counter on the right node to 10.
  3. We trigger a callback to re-render only the components using the state.

This approach is similar to how the DOM handles events: when an event listener is triggered, only the elements directly listening to it are affected. We treat components as observers of the state, preventing unnecessary re-renders across the application.

Creating the Global State File

First, let’s create a global-state.ts file inside the src folder. This file will contain the functions that manage our state.

1mkdir src && touch ./src/global-state.ts

Get State and Update State

We’ll start by defining a state function to initialize and manage our state.

1type setCallbackType<StateType> = (value: StateType) => void;
2
3type StateResponse<StateType> = {
4 get: () => StateType;
5 set: setCallbackType<StateType>;
6};
7
8export function createState<T = unknown>(initialValue: T): StateResponse<T> {
9 return {};
10}
  1. We define a type for the set method, which updates the state value.
  2. The StateResponse type specifies the structure of the return value from our state function.
  3. The StateResponse uses a generic StateType.

Next, we implement the set method, which updates the state. We initialize currentValue with initialValue, define the set function, and return it.

1export function createState<T = unknown>(initialValue: T): StateResponse<T> {
2 let currentValue = initialValue;
3
4 const set: setCallbackType<T> = (value) => {
5 currentValue = value;
6 };
7
8 return {
9 set,
10 };
11}

To finalize, we add the get method, which simply returns the current state value.

1const get = (): T => {
2 return currentValue;
3};
4
5return {
6 set,
7 get,
8};

Sharing State Between Components

Now, we need to handle how state changes are shared between components using subscriptions and triggers. We start by creating a Set to store unique subscriber callbacks, which will be called when the state changes.

1export function createState<T = unknown>(initialValue: T): StateResponse<T> {
2 let currentValue = initialValue;
3
4 const subscribers = new Set<setCallbackType<T>>();
5
6 const get = (): T => {
7 return currentValue;
8 };
9
10 const set: setCallbackType<T> = (value) => {
11 currentValue = value;
12 subscribers.forEach((currentCallback) => {
13 currentCallback(value);
14 });
15 };
16
17 const trigger: triggerType<T> = (callback) => {
18 subscribers.add(callback);
19 return () => {
20 subscribers.delete(callback);
21 };
22 };
23
24 return {
25 get,
26 set,
27 trigger,
28 };
29}

Trigger Function

The trigger function registers a callback with the subscribers and returns a function to remove the callback when the component unmounts.

1const trigger: triggerType<T> = (callback) => {
2 subscribers.add(callback);
3 return () => {
4 subscribers.delete(callback);
5 };
6};

Complete State Function

Here’s the complete implementation of our createState function, which includes get, set, and trigger methods:

1export function createState<T = unknown>(initialValue: T): StateResponse<T> {
2 let currentValue = initialValue;
3
4 const subscribers = new Set<setCallbackType<T>>();
5
6 const get = (): T => {
7 return currentValue;
8 };
9
10 const set: setCallbackType<T> = (value) => {
11 currentValue = value;
12 subscribers.forEach((currentCallback) => {
13 currentCallback(value);
14 });
15 };
16
17 const trigger: triggerType<T> = (callback) => {
18 subscribers.add(callback);
19 return () => {
20 subscribers.delete(callback);
21 };
22 };
23
24 return {
25 get,
26 set,
27 trigger,
28 };
29}

Creating a Custom Hook

Next, we’ll create useGlobalState, a custom hook that helps us create state and register triggers:

1import { useEffect, useState } from "react";
2
3export function useGlobalState<T = unknown>(store: StateResponse<T>) {
4 const [value, setValue] = useState(store.get());
5
6 useEffect(() => {
7 const fallback = store.trigger(setValue);
8 return () => {
9 fallback();
10 };
11 }, [store]);
12
13 return [value, store.set];
14}

Testing the State Manager

To test our state manager, let’s create two simple components that interact with the global state:

1import { createState, useGlobalState } from "./global-state";
2
3const counterStore = createState(0);
4
5function Update() {
6 const [counter, setCounter] = useGlobalState(counterStore);
7
8 return (
9 <div>
10 <button
11 onClick={() => {
12 setCounter(counter + 1);
13 }}
14 >
15 Increment {counter}
16 </button>
17 </div>
18 );
19}
20
21function View() {
22 const [counter] = useGlobalState(counterStore);
23
24 return (
25 <div>
26 <p>View Component</p>
27 <p>Counter is: {counter}</p>
28 </div>
29 );
30}
31
32function App() {
33 return (
34 <>
35 <Update />
36 <View />
37 </>
38 );
39}
40
41export default App;

Run the app with Vite or another tool to see it in action.

Improvements and Final Thoughts

Congratulations! You’ve built a basic but effective React state manager. However, there are ways to improve it further. For example, replacing useEffect with useSyncExternalStore can enhance performance by synchronizing state updates more efficiently.

If you have any questions or want to explore further, here are some helpful resources:

  1. Jotai - Primitive and flexible state management for React
  2. React - The library for web and native user interfaces