How to create global state management in React applications without side dependencies and unnecessary rerendering
First of all, I would like to give special thanks to Jack Herrington for inspiring this topic.
React has made a significant advance in the tools that help us create global state management. React has pushed us to a new level, and Reduxās huge boilerplate base and Mobxās decorators have sunk into oblivion. Creating your own global state management without side dependencies is an easy challenge.
Today, weāll learn about theĀ useSyncExternalStore
Ā hook and add polish to create state management without unnecessarily rendering our components.
Application structure
Before diving into the code, letās check the structure of our test application. Each component will iterate with the global store. We need that structure to know which component will be rerendered with the storeās manipulation.
Create components
To simplify the development process, I will useĀ
Here are a few steps to get started:
- RunĀ
yarn create vite
- Choose your project name, framework:Ā
React
, and language variant:Ājavascript
- Add the following:Ā
cd project_name
Ā andĀyarn
- RemoveĀ
<React.StrictMode>
Ā inĀmain.jsx
Ā to avoid a second rerender
Now, letās create components inĀ src/components
. There should be four files with the following names:Ā Header.jsx
,Ā Body.jsx
,Ā Shares.jsx
, andĀ Footer.jsx
.
Create store
In theĀ src
Ā folder, create theĀ store
Ā folder and put the following two files into it:Ā initialState.js
Ā andĀ index.js
.
ForĀ initialState.js
, I put some nested objects and values that will get updated in our application.
Hereās what the code looks like:
export const initialState = {
name: "John",
age: 30,
status: "active",
details: {
shares: [
{
symbol: "AAPL",
price: 100,
},
{
symbol: "MSFT",
price: 200,
},
{
symbol: "GOOG",
price: 300,
},
],
},
};
Before creating the function, letās figure out how the store should work and what we should expect. Similar to Redux, we can useĀ useState
Ā hooks orĀ useContexts
Ā withĀ useReducer
Ā and apply them across the application.
Letās check the implementation withĀ useState
, as shown below:
import { useState } from "react";
import { initialState } from "./initialState.js";
function createStore() {
return {
useStore() {
return useState(initialState);
},
};
}
export default createStore();
As you can see, weāre gonna reuse theĀ useState
Ā across the application. No magic; itās a simple implementation that clarifies which component will be rerendered after manipulating the store.
Letās update ourĀ App.jsx
Ā withĀ store.useStore()
:
import "./App.css";
import { Footer } from "./components/Footer/index.jsx";
import store from "./store/index.js";
function App() {
console.log("App updated");
const [state, setState] = store.useStore();
return (
<div>
<div>My value: {state.name}</div>
<div>My value: {state.age}</div>
<button
onClick={() =>
setState((prevStore) => {
return {
...prevStore,
name: "New name",
age: 100,
};
})
}
>
Update Shares From App
</button>
<Footer />
</div>
);
}
And letās haveĀ Footer.jsx
Ā access the current state with the following code:
import store from "../../store";
export const Footer = () => {
const [state] = store.useStore();
console.log("Footer updated");
return (
<footer>
<p>Footer</p>
<p>Status: {state.status}</p>
</footer>
);
};
Now run the app withĀ yarn dev
Ā and hit the buttonĀ Update Shares From App
with an open console.
You will see that all of our components are updated. InĀ Footer
, we will readĀ status
Ā from the unmodified store, and this will always returnĀ active
.
But the problem is we didnāt update any values inĀ Footer
Ā because we got an updated object that rerendered the component. To avoid rerendering, we will create a selector and read the store from the hookĀ useSelector
. Hereās how to do that:
const status = useSelector((state) => state.status);
The hook will use the function selector to get the current state from our store.
Update components
Now, letās create our remaining components. In each component, weāre gonna addĀ console.log
Ā to the name of that component. An alternative solution is to use Google Chrome with React Developer Tools.
Hereās what that looks like:
Now,Ā Footer.jsx
Ā will useĀ useSelector
Ā from the created store.
import { useSelector } from "../../store";
export const Footer = () => {
console.log("Footer updated");
const status = useSelector((state) => state.status);
return (
<footer>
<p>Footer</p>
<p>Name: {status}</p>
</footer>
);
};
For theĀ Header
, weāre gonna only useĀ setState
Ā from store.
import { setState } from "../../store";
export const Header = () => {
console.log("Header updated");
return (
<header>
<p>Header</p>
<button
onClick={() =>
setState((prevStore) => {
return {
...prevStore,
name: "Michael",
age: 99,
};
})
}
>
Update Name And Age from Header
</button>
</header>
);
};
And for theĀ Body
, weāre gonna use both functions to update and read the store.
import { useSelector, setState } from "../../store";
export const Body = () => {
const name = useSelector((state) => state.name);
const age = useSelector((state) => state.age);
console.log("Body updated");
return (
<div className="body">
<h1>Body</h1>
<p>Name: {name}</p>
<p>Age: {age}</p>
<button
onClick={() =>
setState((prevStore) => {
return {
...prevStore,
name: "Michael",
age: 99,
};
})
}
>
Update Name And Age from Body
</button>
</div>
);
};
And the last section,Ā Shares
, will use the store to read data.
import { useSelector } from "../../store";
export const Shares = () => {
const { shares } = useSelector((state) => state.details);
console.log("Shares updated")
return (
<div className="shares">
<h1>Shares</h1>
<ul>
{shares.map(({ symbol, price }) => {
return (
<li key={price + symbol}>
{symbol} : {price}
</li>
);
})}
</ul>
</div>
);
};
Finally, to wrap them all in one application, letās put the components intoĀ App.jsx
. To check theĀ App
Ā componentās render as well, weāll use theĀ setState
Ā function.
import "./App.css";
import { Header } from "./components/Header/index.jsx";
import { Body } from "./components/Body/index.jsx";
import { Shares } from "./components/Shares/index.jsx";
import { Footer } from "./components/Footer/index.jsx";
import { setState } from "./store/index.js";
function App() {
console.log("App updated");
return (
<div>
<Header />
<Body />
<Shares />
<button
onClick={() =>
setState((prevStore) => {
const newShare = {
symbol: "XRP",
price: 1.27472,
};
const share = prevStore.details.shares.find(
(share) => share.symbol === newShare.symbol
);
if (!share) {
return {
...prevStore,
details: {
...prevStore.details,
shares: [...prevStore.details.shares, newShare],
},
};
} else {
return prevStore;
}
})
}
>
Update Shares From App
</button>
<Footer />
</div>
);
}
export default App;
To highlight our components, letās useĀ App.css
Ā to add borders.
header, footer, .body, .shares {
border: 1px solid #2c2c2c;
}
Store and Listeners
To prevent unnecessary redrawing, we need to create aĀ useSelector
function. This will improveĀ createStore
ās implementation withĀ subscribe
.Ā Subscribe
Ā notifies React about store changes. InsideĀ createStore.js
, letās create theĀ subscribe
Ā function withĀ listeners
.
function createStore() {
let state = initialState;
const listeners = new Set();
const subscribe = (listener) => {
listeners.add(listener);
return () => listeners.delete(listener);
};
// ...
With this technic, we can subscribe to our store and notify React about changes. As you can see, this function will also returnĀ listeners.delete
Ā and allow us to unsubscribe. This technic came from theĀ setState
.
const subscribe = (listener) => {
listeners.add(listener);
return () => listeners.delete(listener);
};
return {
setState(callback) {
state = callback(state);
listeners.forEach((listener) => listener(state));
}
}
The listener is always gonna get the current state and set it toĀ listeners
.
And the last part, theĀ createStore
Ā function, uses theĀ useSelector
Ā hook and lets us get all the changes from our store.
useSelector(selector) {
return selector(state)
},
But in this case, we are not gonna be able to get updated data because we are not subscribed to our changes from the state. To fix that, we have to apply theĀ subscribe
Ā function to theĀ useSyncExternalStore
Ā hook from React.
This hook takes three arguments:Ā subscribe
,Ā getSnapshot
, andĀ getServerSnapshot
to render on the server side.
useSelector(selector) {
return useSyncExternalStore(
subscribe,
() => selector(state)
);
}
TheĀ subscribe
function will register a callback to notify us about store changes. And combiningĀ () => selector(state)
Ā andĀ getSnapshot
Ā will return our storeās current state. In this case, we wonāt be using server-side rendering for a while.
import { useSyncExternalStore } from "react";
import { initialState } from "./initialState.js";
function createStore() {
let state = initialState;
const listeners = new Set();
const subscribe = (listener) => {
listeners.add(listener);
return () => listeners.delete(listener);
};
return {
setState(callback) {
state = callback(state);
listeners.forEach((listener) => listener(state));
},
useSelector(selector) {
return useSyncExternalStore(subscribe, () => selector(state));
},
};
}
const store = createStore();
export const { setState, useSelector } = store;
Now, letās run our serverĀ yarn dev
Ā and check how the components will re-render. You will see something like this:
By clicking on the button,Ā Update Shares From App
, the storeās data will update. This data is used only inĀ Shares.jsx
Ā , and thatās the only component that has to be rerendered because other components didnāt receive updates.
Now, click onĀ Update Name And Age from Header
, and you will see that updates only happen inĀ Body.jsx
. And if you click again, nothing is gonna rerender because the data is the same. This is absolutely fine.
What About Server-Side Rendering
To sync the server-side data and store, we need to improve theĀ createStore
function. To test that, I suggest you create aĀ Next JS
Ā application and apply our created components to theĀ index
Ā view. While youāre at it, add theĀ getServerSideProps
Ā function to provide additional changes to the storeās data.
export async function getServerSideProps() {
return {
props: {
initialState: {
...initialState,
name: "Black",
},
},
};
}
To apply new store data from our view, we have to initialize our store with server data fromĀ props
.
export default function Home(props) {
console.log("Home updated");
store.init(props.initialState);
return (
<div>
<Header />
<Body />
<Shares />
<Footer />
</div>
);
}
TheĀ initĀ
function should get a new state and apply that to our current state. Hereās what that looks like:
import { useSyncExternalStore } from "react";
import { initialState } from "./initialState.js";
function createStore() {
let state = initialState;
let listeners = new Set();
let isInitialized = false;
// ...
return {
init: (initState) => {
if (!isInitialized) {
state = initState;
}
},
// ...
};
}
const store = createStore();
export default store;
export const { setState, useSelector } = store;
The assignment will happen only once for theĀ view
.
Conclusion
Itās fascinating! With one function, we solved the global state management problem without any boilerplate code or unnecessary re-rendering. The hookĀ useSyncExternalStore
Ā helps us synchronize our store with our React applicationās state. Just one function can connect our global storeās values across the entire application.
Resources
GitHub Repo:Ā
Also published here.