Developer way

How to write performant React code: rules, patterns, do's and don'ts

Nadia Makarevich

Nadia Makarevich

Jan 9, 2022

Nadia MakarevichNadia Makarevich

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: out 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).

1export const Page = ({ countries }: { countries: Country[] }) => {
2 const [selectedCountry, setSelectedCountry] = useState<Country>(countries[0]);
3 const [savedCountry, setSavedCountry] = useState<Country>(countries[0]);
4
5 return (
6 <>
7 <h1>Country settings</h1>
8 <div css={contentCss}>
9 <CountriesList
10 countries={countries}
11 onCountryChanged={(c) => setSelectedCountry(c)}
12 savedCountry={savedCountry}
13 />
14 <SelectedCountry
15 country={selectedCountry}
16 onCountrySaved={() => setSavedCountry(selectedCountry)}
17 />
18 </div>
19 </>
20 );
21};

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:

1export const Page = ({ countries }: { countries: Country[] }) => {
2 // ... same as before
3
4 const onCountryChanged = useCallback((c) => setSelectedCountry(c), []);
5 const onCountrySaved = useCallback(() => setSavedCountry(selectedCountry), []);
6
7 return (
8 <>
9 ...
10 <CountriesList
11 onCountryChanged={onCountryChange}
12 />
13 <SelectedCountry
14 onCountrySaved={onCountrySaved}
15 />
16 ...
17 </>
18 );
19};

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:

1const CountriesList = () => {
2 console.log("Re-render!!!!!");
3 return <div>countries list, always re-renders</div>;
4};
5
6export const Page = ({ countries }: { countries: Country[] }) => {
7 const [counter, setCounter] = useState<number>(1);
8
9 return (
10 <>
11 <h1>Country settings</h1>
12 <button onClick={() => setCounter(counter + 1)}>
13 Click here to re-render Countries list (open the console) {counter}
14 </button>
15 <CountriesList />
16 </>
17 );
18};

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.

1export const Page = ({ countries }: { countries: Country[] }) => {
2 const [counter, setCounter] = useState<number>(1);
3
4 const list = useMemo(() => {
5 return <CountriesList />;
6 }, []);
7
8 return (
9 <>
10 <h1>Country settings</h1>
11 <button onClick={() => setCounter(counter + 1)}>
12 Click here to re-render Countries list (open the console) {counter}
13 </button>
14 {list}
15 </>
16 );
17};

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.

1const list = useMemo(() => {
2 return (
3 <>
4 {counter}
5 <CountriesList />
6 </>
7 );
8}, [counter]);
See the codesandbox example.

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

1export const Page = ({ countries }: { countries: Country[] }) => {
2 const [selectedCountry, setSelectedCountry] = useState<Country>(countries[0]);
3 const [savedCountry, setSavedCountry] = useState<Country>(countries[0]);
4
5 return (
6 <>
7 <h1>Country settings</h1>
8 <div css={contentCss}>
9 <CountriesList
10 countries={countries}
11 onCountryChanged={(c) => setSelectedCountry(c)}
12 savedCountry={savedCountry}
13 />
14 <SelectedCountry
15 country={selectedCountry}
16 onCountrySaved={() => setSavedCountry(selectedCountry)}
17 />
18 </div>
19 </>
20 );
21};

we’ll see that:

  • selectedCountry state is never used in CountriesList component
  • savedCountry state is never used in SelectedCountry 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:

1export const Page = ({ countries }: { countries: Country[] }) => {
2 const [selectedCountry, setSelectedCountry] = useState<Country>(countries[0]);
3 const [savedCountry, setSavedCountry] = useState<Country>(countries[0]);
4
5 const list = useMemo(() => {
6 return (
7 <CountriesList
8 countries={countries}
9 onCountryChanged={(c) => setSelectedCountry(c)}
10 savedCountry={savedCountry}
11 />
12 );
13 }, [savedCountry, countries]);
14
15 const selected = useMemo(() => {
16 return (
17 <SelectedCountry
18 country={selectedCountry}
19 onCountrySaved={() => setSavedCountry(selectedCountry)}
20 />
21 );
22 }, [selectedCountry]);
23
24 return (
25 <>
26 <h1>Country settings</h1>
27 <div css={contentCss}>
28 {list}
29 {selected}
30 </div>
31 </>
32 );
33};

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:

1type CountriesListProps = {
2 countries: Country[];
3 onCountryChanged: (country: Country) => void;
4 savedCountry: Country;
5};
6
7export const CountriesList = ({
8 countries,
9 onCountryChanged,
10 savedCountry
11}: CountriesListProps) => {
12 const Item = ({ country }: { country: Country }) => {
13 // different className based on whether this item is "saved" or not
14 const className = savedCountry.id === country.id ? "country-item saved" : "country-item";
15
16 // when the item is clicked - trigger the callback from props with the correct country in the arguments
17 const onItemClick = () => onCountryChanged(country);
18 return (
19 <button className={className} onClick={onItemClick}>
20 <img src={country.flagUrl} />
21 <span>{country.name}</span>
22 </button>
23 );
24 };
25
26 return (
27 <div>
28 {countries.map((country) => (
29 <Item country={country} key={country.id} />
30 ))}
31 </div>
32 );
33};

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 both onCountryChanged and savedCountry)
  • 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:

1const CountriesList = ({ countries }: { countries: Country[] }) => {
2 const Item = ({ country }: { country: Country }) => {
3 useEffect(() => {
4 console.log("Mounted!");
5 }, []);
6 console.log("Render");
7 return <div>{country.name}</div>;
8 };
9
10 return (
11 <>
12 {countries.map((country) => (
13 <Item country={country} />
14 ))}
15 </>
16 );
17};

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.

1const Item = ({ country }: { country: Country }) => {
2 useEffect(() => {
3 console.log("Mounted!");
4 }, []);
5 console.log("Render");
6 return <div>{country.name}</div>;
7};
8
9const CountriesList = ({ countries }: { countries: Country[] }) => {
10 return (
11 <>
12 {countries.map((country) => (
13 <Item country={country} />
14 ))}
15 </>
16 );
17};

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:

1export const CountriesList = ({
2 countries,
3 onCountryChanged,
4 savedCountry
5}: CountriesListProps) => {
6
7 // only "country" in props
8 const Item = ({ country }: { country: Country }) => {
9 // ... same code
10 };
11
12 return (
13 <div>
14 {countries.map((country) => (
15 <Item country={country} key={country.id} />
16 ))}
17 </div>
18 );
19};

After:

1type ItemProps = {
2 country: Country;
3 savedCountry: Country;
4 onItemClick: () => void;
5};
6
7// turned out savedCountry and onItemClick were also used
8// but it was not obvious at all in the previous implementation
9const Item = ({ country, savedCountry, onItemClick }: ItemProps) => {
10 // ... same code
11};
12
13export const CountriesList = ({
14 countries,
15 onCountryChanged,
16 savedCountry
17}: CountriesListProps) => {
18 return (
19 <div>
20 {countries.map((country) => (
21 <Item
22 country={country}
23 key={country.id}
24 savedCountry={savedCountry}
25 onItemClick={() => onCountryChanged(country)}
26 />
27 ))}
28 </div>
29 );
30};

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:

1const SelectedCountry = ({ country, onSaveCountry }: { country: Country; onSaveCountry: () => void }) => {
2 return (
3 <>
4 <ul>
5 <li>Country: {country.name}</li>
6 ... // whatever country's information we're going to render
7 </ul>
8 <button onClick={onSaveCountry} type="button">Save</button>
9 </>
10 );
11};

🤷🏽‍♀️ 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:

1type Mode = 'light' | 'dark';
2type Theme = { mode: Mode };
3const ThemeContext = React.createContext<Theme>({ mode: 'light' });
4
5const useTheme = () => {
6 return useContext(ThemeContext);
7};

Adding context provider and the button to switch it to the Page component:

1export const Page = ({ countries }: { countries: Country[] }) => {
2 // same as before
3 const [mode, setMode] = useState<Mode>("light");
4
5 return (
6 <ThemeContext.Provider value={{ mode }}>
7 <button onClick={() => setMode(mode === 'light' ? 'dark' : 'light')}>Toggle theme</button>
8 // the rest is the same as before
9 </ThemeContext.Provider>
10 )
11}

And then using the context hook to color our buttons in the appropriate theme:

1const Item = ({ country }: { country: Country }) => {
2 const { mode } = useTheme();
3 const className = `country-item ${mode === "dark" ? "dark" : ""}`;
4 // the rest is the same
5}

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?

1const Item = ({ country }: { country: Country }) => {
2 console.log("render");
3 return <div>{country.name}</div>;
4};
5
6const CountriesList = ({ countries }: { countries: Country[] }) => {
7 return (
8 <>
9 {countries.map((country) => (
10 <Item country={country} />
11 ))}
12 </>
13 );
14};
15
16export const Page = ({ countries }: { countries: Country[] }) => {
17 const [counter, setCounter] = useState<number>(1);
18
19 const list = useMemo(() => <CountriesList countries={countries} />, [
20 countries
21 ]);
22
23 return (
24 <>
25 <h1>Country settings</h1>
26 <button onClick={() => setCounter(counter + 1)}>
27 Click here to re-render Countries list (open the console) {counter}
28 </button>
29 {list}
30 </>
31 );
32};

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:

1export const Page = ({ countries }: { countries: Country[] }) => {
2 // everything else stays the same
3
4 // memoised list is still memoised
5 const list = useMemo(() => <CountriesList countries={countries} />, [
6 countries
7 ]);
8
9 return (
10 <ThemeContext.Provider value={{ mode }}>
11 // same
12 </ThemeContext.Provider>
13 );
14};

And context in the Item component:

1const Item = ({ country }: { country: Country }) => {
2 const theme = useTheme();
3 console.log("render");
4 return <div>{country.name}</div>;
5};

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:

1export const Page = ({ countries }: { countries: Country[] }) => {
2 // everything else stays the same
3
4 // memoising the object!
5 const theme = useMemo(() => ({ mode }), [mode]);
6
7 return (
8 <ThemeContext.Provider value={theme}>
9 // same
10 </ThemeContext.Provider>
11 );
12};

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:

1export const Page = ({ countries }: { countries: Country[] }) => {
2 // same as before
3 const [mode, setMode] = useState<Mode>("light");
4
5 // memoising the object!
6 const theme = useMemo(() => ({ mode }), [mode]);
7
8 return (
9 <ThemeContext.Provider value={theme}>
10 <button onClick={() => setMode(mode === 'light' ? 'dark' : 'light')}>Toggle theme</button>
11 // the rest is the same as before
12 </ThemeContext.Provider>
13 )
14}

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 props or state have 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:

1const MemoisedItem = React.memo(Item);
2
3const List = () => {
4 // this HAS TO be memoised, otherwise `React.memo` for the Item is useless
5 const onClick = () => {console.log('click!')};
6
7 return <MemoisedItem onClick={onClick} country="Austria" />
8}

or this:

1const MemoisedItem = React.memo(Item, (prev, next) => prev.onClick !== next.onClick);
2
3const List = () => {
4 // this HAS TO be memoised, otherwise `React.memo` for the Item is useless
5 const onClick = () => {console.log('click!')};
6
7 return <MemoisedItem onClick={onClick} country="Austria" />
8}

Second: if the component that received the callback has this callback as a dependency in hooks like useMemo, useCallback or useEffect.

1const Item = ({ onClick }) => {
2 useEffect(() => {
3 // some heavy calculation here
4 const data = ...
5 onClick(data);
6
7 // if onClick is not memoised, this will be triggered on every single render
8 }, [onClick])
9 return <div>something</div>
10}
11const List = () => {
12 // this HAS TO be memoised, otherwise `useEffect` in Item above
13 // will be triggered on every single re-render
14 const onClick = () => {console.log('click!')};
15
16 return <Item onClick={onClick} country="Austria" />
17}

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 ✌🏼

Share on:Share "How to write performant React code: rules, patterns, do's and don'ts" on TwitterShare "How to write performant React code: rules, patterns, do's and don'ts" on LinkedInShare "How to write performant React code: rules, patterns, do's and don'ts" on Reddit
Get the latest content by email
    No spam or ads. Unsubscribe at any time.
    © Developer Way 2021