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.
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.
To simplify the development process, I will use
Here are a few steps to get started:
yarn create vite
React
, and language variant: javascript
cd project_name
and yarn
<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
.
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.
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;
}
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.
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
.
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.
GitHub Repo:
Also published here.