So you want to use useState
and useContext
as a state management solution, but every time the value of the context provider changes the entire tree gets re-rendered. You could use a library like Recoil, Valtio, Zustand, and Jotai to get around this problem, but you’d have to change how you store and access global state.
Is there a way to just fix the issues with useContext
? Glad you asked! Yes there is! It’s react-tracked, a new library from Daishi Kato, who has been doing amazing work in the React state management space.
Setting up Your useState/useContext Global Store
The first thing you need to do is set up your store properly. Daishi has some excellent documentation on this already, but let’s walk through the Typescript version of the store step by step.
First we create a store.tsx
file and start that file with some React imports, as well as the structure of the store and a function that creates the useState hook.
import React, { createContext, useState, useContext } from 'react';
const initialState = {
text1: "text1",
text2: "hello",
};
const useMyState = () => useState(initialState);
Our initial store is pretty simple, we have a couple of pieces of text, and we have a function that invokes the React useState hook with that initial state.
Why don’t we just call useState
right there and cache the result? Because React hooks need to be called from within a React component so they can be bound to a component instance. Thus we need a function that will create the state when we need it.
The next step is to create the context:
const MyContext = createContext<ReturnType<typeof useMyState> | null>(null);
This is a standard createContext
call where the context will either hold null
(at startup) or the return type from the useMyState
call. Which will be the standard useState return of an array with the current value, and a setter function.
After that we need to create the SharedStateProvider
React functional component:
const MyContext = createContext<ReturnType<typeof useMyState> | null>(null);
export const SharedStateProvider: React.FC = ({ children }) => (
<MyContext.Provider value={useMyState()}>
{children}
</MyContext.Provider>
);
This component goes at the top of the React tree and provides the context down to any child components that way to consume it. Notice that we are invoking useMyState
at this time because we are in the context of the React component and it’s safe to do so.
And our final step is to create a custom hook that gets the state and the state setter:
export const useSharedState = () => {
const value = useContext(MyContext);
if (value === null)
throw new Error('Please add SharedStateProvider');
return value;
};
This custom hook first uses useContext
to get the context. It then checks to make sure it has that context and throws an error if it doesn’t. And then finally it returns the context, which would be the output of useState
, so an array with a value and a setter.
Now our global store setup is done. No libraries. Just basic React with hooks and structured in a really clean way.
Using the Store
Now that we have our store defined we first import the SharedStateProvider
and add it to our App
like so:
import { SharedStateProvider } from "./store";
const App = () => (
<SharedStateProvider>
...
</SharedStateProvider>
);
This will not only provide the context down to any component that wants to consume it, but also initialize the state to the value in initialState
.
Finally we could add some components that use that state, like so:
import { useSharedState} from "./store";
const Input1 = () => {
const [state, setState] = useSharedState();
return (
<input
value={state.text1}
onChange={(evt) =>
setState({
...state,
text1: evt.target.value,
})
}
/>
);
};
const Text1 = () => {
const [state] = useSharedState();
return (
<div>
{state.text1}
<br />
{Math.random()}
</div>
);
};
const Text2 = () => {
const [state] = useSharedState();
return (
<div>
{state.text2}
<br />
{Math.random()}
</div>
);
};
Now this code will work just fine. But you’ll notice that the Text2
component, which will never need to be updated because we have no way to update the text2
value it’s looking at will get updated any time the global state changes.
This is because React has no way to track what parts of the state the components are looking at. It doesn’t do that work for you, and that ends up being a performance problem when you have a lot of global state. Even the most minor change will end up re-rendering a bunch of components that don’t need re-rendering.
You can see that in this example because the random number on Text2
will keep changing when you type characters into Input1
.
As you can see above, I’m not changing text2 and yet the component showing the text2
value is re-rendering.
React-Tracked to the Rescue
To fix this we bring in the 5Kb react-tracked
library by adding it to our application:
npm install react-tracked
And from there we go back to the store.tsx
file and import the createContainer
function from the library:
import { createContainer } from "react-tracked";
We then remove the definitions for useSharedState
and SharedStateProvider
and add the following code:
export const {
Provider: SharedStateProvider,
useTracked: useSharedState,
} = createContainer(useMyState);
The createContainer
function takes the state creation function:
const useMyState = () => useState(initialState);
And it then returns a Provider
and a useTracked
which are remapped on export to SharedStateProvider
and useSharedState
which is what the components are expecting.
The result is that an isolation where components only re-render if the data they are "tracking" is changed, this is shown below:
Now when I change text1
only the Text1
component changes.
Not bad for just five 5Kb of additional code.
Conclusion
Daishi Kato’s react-tracked
library is an easy way to take a well factored useState/useContext
state management solution and make it performant by intelligently tracking which parts of the state are used by each component.
Video Version
Check out this Blue Collar Code Short Take on react-tracked if you want a video version of this article.
Top comments (0)