Nadia Makarevich
How to write performant React code: rules, patterns, do's and don'ts
Performance and React! Such a fun topic with so many controversial opinions and so many best practices flipping to be the opposite in just 6 months. Is it even possible to say anything definitive here or to make any generalized recommendations?
Usually, performance experts are the proponents of “premature optimisation is the root of all evil” and “measure first” rules. Which loosely translates into “don’t fix that is not broken” and is quite hard to argue with. But I’m going to anyway 😉
What I like about React, is that it makes implementing complicated UI interactions incredibly easy. What I don’t like about React, is that it also makes it incredibly easy to make mistakes with huge consequences that are not visible right away. The good news is, it’s also incredibly easy to prevent those mistakes and write code that is performant most of the time right away, thus significantly reducing the time and effort it takes to investigate performance problems since there will be much fewer of those. Basically, “premature optimisation”, when it comes to React and performance, can actually be a good thing and something that everyone should do 😉. You just need to know a few patterns to watch out for in order to do that meaningfully.
So this is exactly what I want to prove in this article 😊. I’m going to do that by implementing a “real-life” app step by step, first in a “normal” way, using the patterns that you’ll see practically everywhere and surely used multiple times by yourself. And then refactor each step with performance in mind, and extract a generalized rule from every step that can be applied to most apps most of the time. And then compare the result in the end.
Let’s begin!
We are going to write one of the “settings” page for an online shop (that we introduced into the previous “Advanced typescript for React developers” articles). On this page, users will be able to select a country from the list, see all the information available for this country (like currency, delivery methods, etc), and then save this country as their country of choice. The page would look something like this:
On the left we’ll have a list of countries, with “saved” and “selected” states, when an item in the list is clicked, in the column on the right the detailed information is shown. When the “save” button is pressed, the “selected” country becomes “saved”, with the different item colour.
Oh, and we’d want the dark mode there of course, it’s 2022 after all!
Also, considering that in 90% of the cases performance problems in React can be summarised as “too many re-renders”, we are going to focus mostly on reducing those in the article. (Another 10% are: “renders are too heavy” and “really weird stuff that need further investigation”.)
Let's structure our app first
First of all, let's take a look at the design, draw imaginary boundaries, and draft the structure of our future app and which components we’d need to implement there:
- a root “Page” component, where we’d handle the “submit” logic and country selection logic
- a “List of countries” component, that would render all the countries in a list, and in the future handle things like filtering and sorting
- “Item” component, that renders the country in the “List of countries”
- a “Selected country” component, that renders detailed information about the selected country and has the “Save” button
This is, of course, not the only possible way to implement this page, that’s the beauty and the curse of React: everything can be implemented in a million ways and there is no right or wrong answer for anything. But there are some patterns that in the long run in fast-growing or large already apps can definitely be called “never do this” or “this is a must-have”.
Let’s see whether we can figure them out together 🙂
Implementing Page component
Now, finally, the time to get our hands dirty and do some coding. Let’s start from the “root” and implement the Page component.
First: we need a wrapper with some styles that renders page title, “List of countries” and “Selected country” components.
Second: our page should receive the list of countries from somewhere, and then pass it to the CountriesList
component so that it could render those.
Third: our page should have an idea of a “selected” country, that will be received from the CountriesList
component and passed to the SelectedCountry
component.
And finally: our page should have an idea of a “saved” country, that will be received from the SelectedCountry
component and passed to the CountriesList
component (and be sent to the backend in the future).
export const Page = ({ countries }: { countries: Country[] }) => {const [selectedCountry, setSelectedCountry] = useState<Country>(countries[0]);const [savedCountry, setSavedCountry] = useState<Country>(countries[0]);return (<><h1>Country settings</h1><div css={contentCss}><CountriesListcountries={countries}onCountryChanged={(c) => setSelectedCountry(c)}savedCountry={savedCountry}/><SelectedCountrycountry={selectedCountry}onCountrySaved={() => setSavedCountry(selectedCountry)}/></div></>);};
That is the entire implementation of the “Page” component, it’s the most basic React that you can see everywhere, and there is absolutely nothing criminal in this implementation. Except for one thing. Curious, can you see it?
Refactoring Page component - with performance in mind
I think it is common knowledge by now, that react re-renders components when there is a state or props change. In our Page component when setSelectedCountry
or setSavedCountry
is called, it will re-render. If the countries array (props) in our Page component changes, it will re-render. And the same goes for CountriesList
and SelectedCountry
components - when any of their props change, they will re-render.
Also, anyone, who spent some time with React, knows about javascript equality comparison, the fact that React does strict equality comparison for props, and the fact that inline functions create new value every time. This leads to the very common (and absolutely wrong btw) belief, that in order to reduce re-renders of CountriesList
and SelectedCountry
components we need to get rid of re-creating inline functions on every render by wrapping inline functions in useCallback
. Even React docs mention useCallback
in the same sentence with “prevent unnecessary renders”! See whether this pattern looks familiar:
export const Page = ({ countries }: { countries: Country[] }) => {// ... same as beforeconst onCountryChanged = useCallback((c) => setSelectedCountry(c), []);const onCountrySaved = useCallback(() => setSavedCountry(selectedCountry), []);return (<>...<CountriesListonCountryChanged={onCountryChange}/><SelectedCountryonCountrySaved={onCountrySaved}/>...</>);};
Do you know the funniest part about it? It actually doesn’t work. Because it doesn’t take into account the third reason why React components are re-rendered: when the parent component is re-rendered. Regardless of the props, CountriesList
will always re-render if Page is re-rendered, even if it doesn’t have any props at all.
We can simplify the Page example into just this:
const CountriesList = () => {console.log("Re-render!!!!!");return <div>countries list, always re-renders</div>;};export const Page = ({ countries }: { countries: Country[] }) => {const [counter, setCounter] = useState<number>(1);return (<><h1>Country settings</h1><button onClick={() => setCounter(counter + 1)}>Click here to re-render Countries list (open the console) {counter}</button><CountriesList /></>);};
And every time we click the button, we’ll see that CountriesList
is re-rendered, even if it doesn’t have any props at all. Codesandbox code is here.
And this, finally, allows us to solidify the very first rule of this article:
Rule #1. If the only reason you want to extract your inline functions in props into useCallback is to avoid re-renders of children components: don’t. It doesn’t work.
Now, there are a few ways to deal with situations like the above, I am going to use the simplest one for this particular occasion: useMemo hook. What it does is it’s essentially “caches” the results of whatever function you pass into it, and only refreshes them when a dependency of useMemo
is changed. If I just extract the rendered CountriesList
into a variable const list = <ComponentList />;
and then apply useMemo
on it, the ComponentList
component now will be re-rendered only when useMemo dependencies will change.
export const Page = ({ countries }: { countries: Country[] }) => {const [counter, setCounter] = useState<number>(1);const list = useMemo(() => {return <CountriesList />;}, []);return (<><h1>Country settings</h1><button onClick={() => setCounter(counter + 1)}>Click here to re-render Countries list (open the console) {counter}</button>{list}</>);};
Which in this case is never, since it doesn’t have any dependencies. This pattern basically allows me to break out of this “parent re-renders - re-render all the children regardless” loop and take control over it. Check out the full example in codesandbox.
The most important thing there to be mindful of is the list of dependencies of useMemo
. If it depends on exactly the same thing that causes the parent component to re-render, then it’s going to refresh its cache with every re-render, and essentially becomes useless. For example, if in this simplified example I pass the counter
value as a dependency to the list
variable (notice: not even a prop to the memoised component!), that will cause useMemo
to refresh itself with every state change and will make CountriesList
re-render again.
const list = useMemo(() => {return (<>{counter}<CountriesList /></>);}, [counter]);
Okay, so all of this is great, but how exactly it can be applied to our non-simplified Page component? Well, if we look closely to its implementation again
export const Page = ({ countries }: { countries: Country[] }) => {const [selectedCountry, setSelectedCountry] = useState<Country>(countries[0]);const [savedCountry, setSavedCountry] = useState<Country>(countries[0]);return (<><h1>Country settings</h1><div css={contentCss}><CountriesListcountries={countries}onCountryChanged={(c) => setSelectedCountry(c)}savedCountry={savedCountry}/><SelectedCountrycountry={selectedCountry}onCountrySaved={() => setSavedCountry(selectedCountry)}/></div></>);};
we’ll see that:
selectedCountry
state is never used inCountriesList
componentsavedCountry
state is never used inSelectedCountry
component
Which means that when selectedCountry
state changes, CountriesList
component doesn’t need to re-render at all! And the same story with savedCountry
state and SelectedCountry
component. And I can just extract both of them to variables and memoise them to prevent their unnecessary re-renders:
export const Page = ({ countries }: { countries: Country[] }) => {const [selectedCountry, setSelectedCountry] = useState<Country>(countries[0]);const [savedCountry, setSavedCountry] = useState<Country>(countries[0]);const list = useMemo(() => {return (<CountriesListcountries={countries}onCountryChanged={(c) => setSelectedCountry(c)}savedCountry={savedCountry}/>);}, [savedCountry, countries]);const selected = useMemo(() => {return (<SelectedCountrycountry={selectedCountry}onCountrySaved={() => setSavedCountry(selectedCountry)}/>);}, [selectedCountry]);return (<><h1>Country settings</h1><div css={contentCss}>{list}{selected}</div></>);};
And this, finally, lets us formalize the second rule of this article:
Rule #2. If your component manages state, find parts of the render tree that don’t depend on the changed state and memoise them to minimize their re-renders.
Implementing the list of countries
Now, that our Page component is ready and perfect, time to flesh out its children. First, let’s implement the complicated component: CountriesList
. We already know, that this component should accept the list of countries, should trigger onCountryChanged
callback when a country is selected in the list, and should highlight the savedCountry
into a different color, according to design. So let’s start with the simplest approach:
type CountriesListProps = {countries: Country[];onCountryChanged: (country: Country) => void;savedCountry: Country;};export const CountriesList = ({countries,onCountryChanged,savedCountry}: CountriesListProps) => {const Item = ({ country }: { country: Country }) => {// different className based on whether this item is "saved" or notconst className = savedCountry.id === country.id ? "country-item saved" : "country-item";// when the item is clicked - trigger the callback from props with the correct country in the argumentsconst onItemClick = () => onCountryChanged(country);return (<button className={className} onClick={onItemClick}><img src={country.flagUrl} /><span>{country.name}</span></button>);};return (<div>{countries.map((country) => (<Item country={country} key={country.id} />))}</div>);};
Again, the simplest component ever, only 2 things are happening there, really:
- we generate the
Item
based on the props we receive (it depends on bothonCountryChanged
andsavedCountry
) - we render that
Item
for all countries in a loop
And again, there is nothing criminal about any of this per se, I have seen this pattern used pretty much everywhere.
Refactoring List of countries component - with performance in mind
Time again to refresh a bit our knowledge of how React renders things, this time - what will happen if a component, like Item
component from above, is created during another component render? Short answer - nothing good, really. From React’s perspective, this Item
is just a function that is new on every render, and that returns a new result on every render. So what it will do, is on every render it will re-create results of this function from scratch, i.e. it will just compare the previous component state with the current one, like it happens during normal re-render. It will drop the previously generated component, including its DOM tree, remove it from the page, and will generate and mount a completely new component, with a completely new DOM tree every single time the parent component is re-rendered.
If we simplify the countries example to demonstrate this effect, it will be something like this:
const CountriesList = ({ countries }: { countries: Country[] }) => {const Item = ({ country }: { country: Country }) => {useEffect(() => {console.log("Mounted!");}, []);console.log("Render");return <div>{country.name}</div>;};return (<>{countries.map((country) => (<Item country={country} />))}</>);};
This is the heaviest operation from them all in React. 10 “normal” re-renders is nothing compared to the full re-mounting of a freshly created component from a performance perspective. In normal circumstances, useEffect
with an empty dependencies array would be triggered only once - after the component finished its mounting and very first rendering. After that the light-weight re-rendering process in React kicks in, and component is not created from scratch, but only updated when needed (that’s what makes React so fast btw). Not in this scenario though - take a look at this codesandbox, click on the “re-render” button with open console, and enjoy 250 renders AND mountings happening on every click.
The fix for this is obvious and easy: we just need to move the Item
component outside of the render function.
const Item = ({ country }: { country: Country }) => {useEffect(() => {console.log("Mounted!");}, []);console.log("Render");return <div>{country.name}</div>;};const CountriesList = ({ countries }: { countries: Country[] }) => {return (<>{countries.map((country) => (<Item country={country} />))}</>);};
Now in our simplified codesandbox mounting doesn’t happen on every re-render of the parent component.
As a bonus, refactoring like this helps maintain healthy boundaries between different components and keep the code cleaner and more concise. This is going to be especially visible when we apply this improvement to our “real” app. Before:
export const CountriesList = ({countries,onCountryChanged,savedCountry}: CountriesListProps) => {// only "country" in propsconst Item = ({ country }: { country: Country }) => {// ... same code};return (<div>{countries.map((country) => (<Item country={country} key={country.id} />))}</div>);};
After:
type ItemProps = {country: Country;savedCountry: Country;onItemClick: () => void;};// turned out savedCountry and onItemClick were also used// but it was not obvious at all in the previous implementationconst Item = ({ country, savedCountry, onItemClick }: ItemProps) => {// ... same code};export const CountriesList = ({countries,onCountryChanged,savedCountry}: CountriesListProps) => {return (<div>{countries.map((country) => (<Itemcountry={country}key={country.id}savedCountry={savedCountry}onItemClick={() => onCountryChanged(country)}/>))}</div>);};
Now, that we got rid of the re-mounting of Item
component every time the parent component is re-rendered, we can extract the third rule of the article:
Rule #3. Never create new components inside the render function of another component.
Implementing selected country
Next step: the “selected country” component, which is going to be the shortest and the most boring part of the article, since there is nothing to show there really: it’s just a component that accepts a property and a callback, and renders a few strings:
const SelectedCountry = ({ country, onSaveCountry }: { country: Country; onSaveCountry: () => void }) => {return (<><ul><li>Country: {country.name}</li>... // whatever country's information we're going to render</ul><button onClick={onSaveCountry} type="button">Save</button></>);};
🤷🏽♀️ That’s it! It’s only here just to make the demo codesandbox more interesting 🙂
Final polish: theming
And now the final step: dark mode! Who doesn’t love those? Considering that the current theme should be available in most components, passing it through props everywhere would be a nightmare, so React Context is the natural solution here.
Creating theme context first:
type Mode = 'light' | 'dark';type Theme = { mode: Mode };const ThemeContext = React.createContext<Theme>({ mode: 'light' });const useTheme = () => {return useContext(ThemeContext);};
Adding context provider and the button to switch it to the Page component:
export const Page = ({ countries }: { countries: Country[] }) => {// same as beforeconst [mode, setMode] = useState<Mode>("light");return (<ThemeContext.Provider value={{ mode }}><button onClick={() => setMode(mode === 'light' ? 'dark' : 'light')}>Toggle theme</button>// the rest is the same as before</ThemeContext.Provider>)}
And then using the context hook to color our buttons in the appropriate theme:
const Item = ({ country }: { country: Country }) => {const { mode } = useTheme();const className = `country-item ${mode === "dark" ? "dark" : ""}`;// the rest is the same}
Again, nothing criminal in this implementation, a very common pattern, especially for theming.
Refactoring theming - with performance in mind.
Before we’ll be able to catch what’s wrong with the implementation above, time to look into a fourth reason why a React component can be re-rendered, that often is forgotten: if a component uses context consumer, it will be re-rendered every time the context provider’s value is changed.
Remember our simplified example, where we memoised the render results to avoid their re-renders?
const Item = ({ country }: { country: Country }) => {console.log("render");return <div>{country.name}</div>;};const CountriesList = ({ countries }: { countries: Country[] }) => {return (<>{countries.map((country) => (<Item country={country} />))}</>);};export const Page = ({ countries }: { countries: Country[] }) => {const [counter, setCounter] = useState<number>(1);const list = useMemo(() => <CountriesList countries={countries} />, [countries]);return (<><h1>Country settings</h1><button onClick={() => setCounter(counter + 1)}>Click here to re-render Countries list (open the console) {counter}</button>{list}</>);};
Page
component will re-render every time we click the button since it updates the state on every click. But CountriesList
is memoised and is independent of that state, so it won’t re-render, and as a result Item
component won’t re-render as well. See the codesandbox here.
Now, what will happen if I add the Theme context here? Provider in the Page
component:
export const Page = ({ countries }: { countries: Country[] }) => {// everything else stays the same// memoised list is still memoisedconst list = useMemo(() => <CountriesList countries={countries} />, [countries]);return (<ThemeContext.Provider value={{ mode }}>// same</ThemeContext.Provider>);};
And context in the Item component:
const Item = ({ country }: { country: Country }) => {const theme = useTheme();console.log("render");return <div>{country.name}</div>;};
If they were just normal components and hooks, nothing would’ve happened - Item
is not a child of Page
component, CountriesList
won’t re-render because of memoisation, so Item
wouldn’t either. Except, in this case, it’s a Provider-consumer combination, so every time the value on the provider changes, all of the consumers will re-render. And since we’re passing new object to the value all the time, Items
will unnecessary re-render on every counter. Context basically bypasses the memorisation we did and makes it pretty much useless. See the codesandbox.
The fix to it, as you might have already guessed, is just to make sure that the value
in the provider doesn’t change more than it needs to. In our case, we just need to memoise it as well:
export const Page = ({ countries }: { countries: Country[] }) => {// everything else stays the same// memoising the object!const theme = useMemo(() => ({ mode }), [mode]);return (<ThemeContext.Provider value={theme}>// same</ThemeContext.Provider>);};
And now the counter will work without causing all the Items to re-render!
And absolutely the same solution for preventing unnecessary re-renders we can apply to our non-simplified Page
component:
export const Page = ({ countries }: { countries: Country[] }) => {// same as beforeconst [mode, setMode] = useState<Mode>("light");// memoising the object!const theme = useMemo(() => ({ mode }), [mode]);return (<ThemeContext.Provider value={theme}><button onClick={() => setMode(mode === 'light' ? 'dark' : 'light')}>Toggle theme</button>// the rest is the same as before</ThemeContext.Provider>)}
And extract the new knowledge into the final rule of this article:
Rule #4: When using context, make sure that value property is always memoised if it’s not a number, string or boolean.
Bringing it all together
And finally, our app is complete! The entire implementation is available in this codesandbox. Throttle your CPU if you’re on the latest MacBook, to experience the world as the usual customers are, and try to select between different countries on the list. Even with 6x CPU reduction, it’s still blazing fast! 🎉
And now, the big question that I suspect many people have the urge to ask: “But Nadia, React is blazing fast by itself anyway. Surely those ‘optimisations’ that you did won’t make much of a difference on a simple list of just 250 items? Aren’t you exaggerating the importance here?“.
Yeah, when I just started this article, I also thought so. But then I implemented that app in the “non-performant” way. Check it out in the codesandbox. I don’t even need to reduce the CPU to see the delay between selecting the items 😱. Reduce it by 6x, and it’s probably the slowest simple list on the planet that doesn’t even work properly (it has a focus bug that the “performant” app doesn’t have). And I haven’t even done anything outrageously and obviously evil there! 😅
So let’s refresh when React components re-render:
- when component's state changed
- when parent component re-renders
- when a component uses context and the value of its provider changes
And the rules we extracted:
Rule #1: If the only reason why you want to extract your inline functions in props into useCallback
is to avoid re-renders of children components: don’t. It doesn’t work.
Rule #2: If your component manages state, find parts of the render tree that don’t depend on the changed state and memoise them to minimize their re-renders.
Rule #3. Never create new components inside the render function of another component.
Rule #4. When using context, make sure that value
property is always memoised if it’s not a number, string or boolean.
That is it! Hope those rules will help to write more performant apps from the get-go and lead to happier customers who never had to experience slow products anymore.
Bonus: the useCallback
conundrum
I feel I need to resolve one mystery before I actually end this article: how can it be possible that useCallback
is useless for reducing re-renders, and why then React docs literally say that “[useCallback] is useful when passing callbacks to optimized child components that rely on reference equality to prevent unnecessary renders”? 🤯
The answer is in this phrase: “optimized child components that rely on reference equality”.
There are 2 scenarios applicable here.
First: the component that received the callback is wrapped in React.memo
and has that callback as a dependency. Basically this:
const MemoisedItem = React.memo(Item);const List = () => {// this HAS TO be memoised, otherwise `React.memo` for the Item is uselessconst onClick = () => {console.log('click!')};return <MemoisedItem onClick={onClick} country="Austria" />}
or this:
const MemoisedItem = React.memo(Item, (prev, next) => prev.onClick !== next.onClick);const List = () => {// this HAS TO be memoised, otherwise `React.memo` for the Item is uselessconst onClick = () => {console.log('click!')};return <MemoisedItem onClick={onClick} country="Austria" />}
Second: if the component that received the callback has this callback as a dependency in hooks like useMemo
, useCallback
or useEffect
.
const Item = ({ onClick }) => {useEffect(() => {// some heavy calculation hereconst data = ...onClick(data);// if onClick is not memoised, this will be triggered on every single render}, [onClick])return <div>something</div>}const List = () => {// this HAS TO be memoised, otherwise `useEffect` in Item above// will be triggered on every single re-renderconst onClick = () => {console.log('click!')};return <Item onClick={onClick} country="Austria" />}
None of this can be generalised into a simple “do” or “don’t do”, it can only be used for solving the exact performance problem of the exact component, and not before.
And now the article is finally done, thank you for reading it so far and hope you found it useful! Bleib gesund and see ya next time ✌🏼