How to make all local state globally accessible and how much it costs in terms of performance in React
roggc
Posted on June 5, 2023
The use case
We will be coding the following scenario:
So we have a use case where we input the number of counters, and then they will be rendered. Each counter has two buttons, the first one controls its own local state, the second one controls the local state of next counter. And also, they show the value of the input text entered (in this case a number) and its name (each instance of a component have a unique name).
We will compare how much it costs to make all local state globally accessible in terms of performance. We will see that the curve is exponential, that is, the time it takes to render all components vs the number of components.
Considerations
Because we don't know what number of counters will be rendered (can be any), we can not lift up state declaration to the upper component in the tree, because it will be variable, and we cannot call each time a different number of hooks.
So we must declare state locally. To make it globally accessible we will use the library react-context-slices, which allows to manage state through Context in an easy and fast way.
Using this library, we will create a slice of context to store there all the values of state (and setters). For this we will create two hooks, useAddState
and useGetState
. The first one will create state with useState
and add the values (value and setter) to the slice of context. The second one will recover those values.
slices.js
First, when using this library, we must create a slices.js
file like this:
import getHookAndProviderFromSlices from "react-context-slices";
export const { useSlice, Provider } = getHookAndProviderFromSlices({
globalState: { initialArg: {} },
});
As you can see we are creating a slice of Context named globalState
, initialising it with an empty object.
useAddState
and useGetState
hooks
Now we define those hooks.
import { useEffect, useState } from "react";
import { useSlice } from "@slices";
export const useAddState = (initialValue, id) => {
const [, setGlobalState] = useSlice("globalState");
const [value, setValue] = useState(initialValue);
useEffect(() => {
setGlobalState((gS) => ({
...gS,
[id]: [value, setValue],
}));
}, [value, id, setGlobalState, setValue]);
return [value, setValue];
};
This hook creates state with useState
and stores it in the Context.
import { useSlice } from "@slices";
export const useGetState = (id) => {
const [globalState] = useSlice("globalState");
return globalState[id] ?? [];
};
This hook recovers state values from Context.
The App
component
import Counter from "@components/counter";
import { useAddState, useGetState } from "@src/hooks";
import { useDeferredValue, useEffect, useMemo, useRef } from "react";
import CounterVisualiser from "@components/counter-visualiser";
const App = () => {
const timeRef = useRef(new Date().getTime());
const [numberOfCounters, setNumberOfCounters] = useAddState(
0,
"app-number-of-components"
);
const [text, setText] = useAddState("", "app-input-text");
const [count] = useGetState("counter0-counter");
useEffect(() => {
if (count === 0) {
const duration = (new Date().getTime() - timeRef.current) / 1000;
console.log("duration", duration, numberOfCounters);
}
}, [count, numberOfCounters]);
const slicesJsx = useMemo(
() =>
Array.from({ length: numberOfCounters }, (_, i) => i).map(
(v, i, array) => (
<Counter
key={v}
componentName={`counter${v}`}
otherComponent={`counter${v === array.length - 1 ? 0 : v + 1}`}
/>
)
),
[numberOfCounters]
);
const jsx = useMemo(() => {
return (
<>
<div>
<input
type="text"
value={text ?? ""}
onChange={(e) => setText(e.target.value)}
/>
<button onClick={() => setNumberOfCounters(parseInt(text))}>
set number of counters
</button>
</div>
<CounterVisualiser id="counter10-counter" />
{slicesJsx}
</>
);
}, [text, slicesJsx, setNumberOfCounters, setText]);
return jsx;
};
export default App;
What this component does is render the jsx and compute the time it takes to the page to be operative, ready to be used and to interact with.
Counter
component
import { useGetState, useAddState } from "@src/hooks";
import { useMemo, useEffect, useDeferredValue } from "react";
const Counter = ({ componentName, otherComponent }) => {
const [count, setCount] = useAddState(0, `${componentName}-counter`);
const [, setCountOtherComponent] = useGetState(`${otherComponent}-counter`);
const [inputText] = useGetState("app-input-text");
const defferredInputTexT = useDeferredValue(inputText);
const jsx = useMemo(() => {
return (
<div>
<button onClick={() => setCount((c) => c + 1)}>+</button>
{count}
<button onClick={() => setCountOtherComponent((c) => c + 1)}>+</button>
{defferredInputTexT}
{componentName}
</div>
);
}, [
count,
defferredInputTexT,
componentName,
setCountOtherComponent,
setCount,
]);
return jsx;
};
export default Counter;
Is important to note the use of useMemo
for the rendered jsx. If we do not use useMemo
, it will compute each time the rendered jsx for all counters, because all counters will render each time any of them change its state, because all of them react to the global state change. So to avoid cost of computation of jsx each time (because only one counter of all of them will effectively change its jsx), we use useMemo
. We do not avoid that each counter renders each time, but at least we optimise the process of rendering a little bit.
Also note how we use useDefferredValue
hook for the input text of the App
component. This allows the user to enter text and deffer the update of this in the counters.
The CounterVisualiser
component
The CounterVisualiser
component, rendered in the jsx of the App
component, is just here to prove that state in this project is reactive, that is, that components consuming the global state updates.
import { useGetState } from "@src/hooks";
import { useDeferredValue } from "react";
const CounterVisualiser = ({ id }) => {
const [count] = useGetState(id);
const defferredCount = useDeferredValue(count);
return <div>{`${id}: ${defferredCount}`}</div>;
};
export default CounterVisualiser;
It just get state from the global state and renders it.
Costs in terms of performance
Here is a plot of the time it takes to the page to be operative on first render vs the number of components:
This is approximately, but we can observe an exponential shape. Let's say that until 4000 it's almost linear. More than 4000 components (counters in this case) the costs in terms of performance are evident.
Summary
The purpose of this project is to know how much it costs in terms of performance make all local state globally accessible.
This implies that all components will render each time any of them change the state (I say 'the state' because it's global and all components are reactive to it).
We see that when there are more than 4000 components in the page performance gets penalised quite a lot. Even 4000 takes the page around 30 seconds to be operative (3000 components takes 20 seconds, 2000 components takes 13 seconds and 1000 components 5 seconds).
So it's cool to have all the components of the page reacting to state changes of any part in the component tree, but we pay a price in terms of performance when we have thousands of components in the page, because all of them will render each time any part of the global state changes.
Thanks for reading and happy coding.
Posted on June 5, 2023
Join Our Newsletter. No Spam, Only the good stuff.
Sign up to receive the latest update from our blog.
Related
November 30, 2024