In this post, I will be introducing a new optimization that will significantly improve your React app performance. In one of my particular case it reduced the amount of react commits from ~200 to just ~2 (You can visualize these in the new React Profiler 🔥 🔥). It's a very specific case, but it proves the utility of the approach and illustrate it's benefits.
Most importantly, we shouldn't be lifting the state up if we are doing that only to set state from another component. Let's understand this by looking at a contrived example.
The problem
I have a React app, where I have implemented a top level <Loader />
component whose job is to either display the loading symbol or not. It looks something like this.
import React, { useState } from "react";
const AppContext = React.createContext();
export default function App() {
const [isVisible, setShowLoader] = useState(false);
return (
<AppContext.Provider value={{ setShowLoader }}>
<div>
{isVisible && <Loader />}
Remainder of my app
</div>
</AppContext.Provider>
);
}
In the above code, you can see that I have a Loader component at the top level, and I have passed it's setter down using the context. Now setShowLoader
is used by various parts of my code to display the loader (primarily before API call) and hide the loader (post call is settled).
By now the problem with this approach is obvious; Since we have this state at the top level component, every time I call setShowLoader
the entire App will go into reconciliation. And since most of us don't do pre optimization, this was re rendering my whole app.
Introducing Mitt
We have a small utility that we have written in our codebase, which is basically a pub/sub model using which we can pass events & data anywhere to anywhere. We can use this to dispatch events from any component to any other component. Upon researching online, I found an excellent package that exists for this purpose.
import mitt from 'mitt';
const emitter = mitt();
// listen to an event
emitter.on('foo', e => console.log('foo', e))
// listen to all events
emitter.on('*', (type, e) => console.log(type, e) )
// fire an event
emitter.emit('foo', { a: 'b' })
// working with handler references:
function onFoo() {}
emitter.on('foo', onFoo) // listen
emitter.off('foo', onFoo) // unlisten
Now with this utility I can communicate between any components in my codebase.
The solution
Now that I know I can communicate from any part of my code to my top level Loader component, I can move my isVisible
state into <Loader />
component. With this, whenever I change my state, only my Loader component will re-render and my entire app re-render is prevented. My final code will look as follows.
import React, { useState } from "react";
import mitt from 'mitt';
const AppContext = React.createContext();
const events = mitt();
export const showLoader = val => {
events.emit("showLoader", val);
};
function Loader() {
const [isVisible, setShowLoader] = useState(false);
useEffect(() => {
events.on("showLoader", setShowLoader);
return () => {
events.off("showLoader", setShowLoader);
};
}, []);
if (isVisible) {
return <div>Loading GIF</div>;
}
return null;
}
export default function App() {
return (
<AppContext.Provider value={{ showLoader }}>
<div>
<Loader />
Remainder of my app
</div>
</AppContext.Provider>
);
}
To summarize
- We can use this whenever we have situation where the state is used in one component (or it's subtree) but is updated from other places in the code
- We shouldn't be lifting the state up if we are doing that only to set state from another component.
- We have depended on a pub/sub model to communicate between component. https://github.com/developit/mitt
- By moving the state of the
Loader
to the Loader component itself, we have avoided re-rendering the entire app.
Also Note: Since I have exported the
showLoader
function from the App, I can import it into any component and use it; instead of taking it from Context.
Top comments (7)
There are multiple other ways to avoid having "the rest of the app" re-render here, including memoizing the rendered child component elements in
App
, wrapping the immediate child inReact.memo()
, and use ofprops.children
.Agreed Mark!
I am suggesting that apart from those optimizations, we can isolate the state & re-renders to the sub-tree where it is used. And if required, we can set it from anywhere in our code.
I am trying to establish that we shouldn't be lifting the state up only because we want to update the state from another place!
This removes React as the potential cause of bottleneck, for example github.com/react-spring/zustand is similar. I guess this depends on your use case.
This optimization (and example) focusses less on State management and more on updating state. And updating state from different and more often unrelated parts of your app.
Yes this pattern can be good for that, I’ve used it in the past where I needed a component to react to the end of an animation from an unrelated component
Isn't moving state out of the react tree concurrent mode unsafe? Or is that not always the case?
In this particular case, I am not moving the state out of the tree. I am still keeping the state properly at the right place in the tree, only the setting the state is happening from all over the place in my code base (In this example I am showing global loader, from different screens).
By keeping state restricted to loader component, I am re rendering only the loader component, which is what I intended when I called my
showLoader
function.