Prevent Unnecessary Re-render in React
.jpeg?table=block&id=309e2534-1c95-8070-b044-e9e6cfa54229&cache=v2)
For proceed into this blog, at least you know basic concept of React, such as: component, state, prop, hook, render.
We only discuss about function component here, no class component. Since that’s where React will going further.
Re-render actually is a normal concept in React.
Re-render mean: React detect a data is changed and React need to update the UI, so the user get the latest update.
Re-render is called unnecessary when we DO NOT change the data explicitly but re-render happened.
Unnecessary re-render is common culprit on bad web performance.
“Why the app is so slow?”
“Why we got infinite loop?”
“Why the API call is called twice?”
Knowing what the cause and how to prevent it will make you a better developer.
This is also a common question if you will have interview for the frontend engineer role.
If you are a junior frontend engineer and want to do more than just building a UI, this is a knowledge to help you level up.
“Should we always DO all of these things?”
Yes, if you want to develop a web app that not tolerating any bad performance.
But, If you just develop a static website or if you think your web performant is good enough then maybe you don’t need to do all of this.
The Cause
There are three cause of the re-render in React:
- parent component is re-render 🟣
- state changes 🟠
- prop changes 🔴
So, unnecessary re-render happened because of those three too.
Let’s talk more about it.
To make it easier, we will use those color to reference each cause next 🟣 🟠 🔴
For (a) 🟣, about parent re-render cause child re-render.
For now let’s say that’s how React itself works.
For (b) 🟠, about state changes.
Why state changes? Because you call
setState.Why
setState cause unnecessary rendering? Maybe because you put it inside an useEffect that firing excessively.Why
useEffect firing excessively? Because it’s dependencies is changed.For (c) 🔴, about prop changes.
Why prop changes? Because the value that you send into children component is changed.
Again, re-render is called unnecessary when we DO NOT change the data explicitly (the passed data and dependencies) but React tell us that props and state is changed. Hence, React do re-render.
But, why is that? Why even though we don’t change the data, React tell us the data is changed?
Because…
In JavaScript, aside from the primitive type (string, number, undefined, etc), object{}is saved by reference, that’s including array[]and function() => {}.
What that mean: is even though we don’t change them, for every render, an object (also array and function) is treated as different object.
The have different object id in memory. And that’s how JavaScript works.
Can we do anything to solve the those three above? Sure, take a look below
1. Use React.memo
React.memo is a function that receive component as an argument.It will make that component ONLY re-render when it’s props and state changed.
It will PREVENT re-render when parent component re-render. So this will fix issue 🟣 (a).
Below is example where even though parent re-render, child with
memo is not re-render because it’s prop not changed.import React from "react"; function App() { // 2. Updating state will trigger re-render const [_, setCounter] = React.useState(0); return ( <> {/* 1. button clicked -> we updating the state */} <button onClick={() => setCounter((counter) => counter + 1)}> Click me to trigger re-render </button> {/* 3a. About1 re-render too because it's parent component re-render */} <About1 /> {/* 3b. About2 will not re-render because wrapped in memo */} <About2 /> </> ); } const About1 = () => { console.log("About1 rendered"); return "about 1 page"; }; const About2 = React.memo(() => { console.log("About2 rendered"); return "about 2 page"; });
2. Use React.useMemo
Notice thatuseMemois DIFFERENT withmemothat we discuss in previous point
We use
useMemo to fix the re-rendering problem (b) 🟠 and (c) 🔴: where JS create new object even though the value inside them is same.useMemo is intended to use for the data other than function (technically, we can use function too).- In example below, we use
useMemoto prevent recreate object when it’s passed as a prop. This is will fix issue (c) 🔴
import React from 'react'; export function App() { // 2. Updating state trigger re-render const [_, setCounter] = React.useState(0); // 3a. Re-render cause React to recreate this object again const data = { name: "Ilham" } // 3b. Will not recreated, only created once on initial render const memoizedData = React.useMemo(() => ({ name: "Ilham" }), []); return ( <> {/* 1. button clicked -> we updating the state */} <button onClick={() => setCounter(counter => counter + 1)}> Click me to trigger re-render </button> {/* 4a. Title 1 re-render, because different reference treated as diff value, */} <Title number={1} data={data} /> {/* 4b. Title 2 will NOT re-render because the object still same */} <Title number={2} data={memoizedData} /> </> ); } // 5. React.memo help us prevent re-render because parent re-render (a) // but it's not prevent re-render because (c) if object reference is changed const Title = React.memo(props => { return <p>props.name</p>; });
b. Now we use
useMemo to prevent excessive firing of useEffect that call useStateimport { useMemo, useState, useEffect } from 'react' function App() { const [_, setCounter] = useState(0) const data = { name: "Ilham" } useEffect(() => { console.log('useEffect 1 firing') }, [data]) const memoizedData = useMemo(() => data, [data]) useEffect(() => { console.log('useEffect 2 firing') }, [memoizedData]) return ( <button onClick={() => setCounter(counter => counter + 1)}> Trigger re-render </button> ) }
3. Use React.useCallback
Actually
useCallback is same with useMemo , but this is for function.We use
useCallback to fix the re-rendering problem (b) 🟠 and (c) 🔴: where JS create new function even though the function is still the same.- In example below, we use
useMemoto prevent recreate function when it’s passed as a props. This is will fix issue (c) 🔴
import React from 'react'; export function App() { // 2. Updating state trigger re-render const [_, setCounter] = React.useState(0); // 3a. Re-render cause React to recreate dummyFunc const dummyFunc = () => {} // 3b. Will not recreated, only created once on initial render const memoizedDummyFunc = React.useCallback(() => {}, []); return ( <> {/* 1. button clicked -> we updating the state */} <button onClick={() => setCounter(counter => counter + 1)}> Click me to trigger re-render </button> {/* 4a. Title 1 re-render, because different reference treated as diff value, */} <Title number={1} func={dummyFunc} /> {/* 4b. Will not re-render because the object still same */} <Title number={2} func={memoizedDummyFunc} /> </> ); } // 5. React.memo help us prevent re-render because parent re-render (a) // but it's not prevent re-render because of (c) object reference is changed const Title = React.memo(props => { return <p>{props.number}</p>; });
b. Now we use
useCallback to prevent excessive firing of useEffect that call useStateimport React from 'react' function App() { const [name, setName] = React.useState("") const memoizedInitData = React.useCallback(() => { setName("Ilham") }, []); React.useEffect(() => { memoizedInitData() }, [memoizedInitData]) return <div>{counter}</div> }
4. Use React.useRef
You want to store a value, but don’t want to trigger re-render when that value updated? Use
useRef.The example of data is: form data, DOM object,
setInterval id, setTimeout id.Let’s take a look below, how I managed to save a input value and the implement cancellable submit by also save the
setTimeout id using useRef.import React from 'react' export function App() { // This log only called on initial render // because re-render NOT happened when the value inside ref is changed console.log("App") const dataRef = React.useRef({}) const timeoutIdRef = React.useRef(null) const actionSubmitForm = (event) => { event.preventDefault() const timeOut = setTimeout(() => { alert(`Data submitted : ${dataRef.current.firstName}`) }, 2000) // 3. setTimeout run // we store the id to being able to cancel it, no re-render timeoutIdRef.current = timeOut } const cancelSubmitForm = () => { clearTimeout(timeoutIdRef.current) } return ( <form onSubmit={actionSubmitForm}> <input placeholder="First name" {/* 1. User type and update the value of ref, no re-render */} onChange={event => { dataRef.current.firstName = event.target.value }} /> <button type="submit"> Submit </button> {/* 2. User submit the form */} <button type="button" onClick={cancelSubmitForm}> Cancel </button> </form> ) }
5. AVOID React.Context
React.Context worked in a way that it will wrap a component tree, thus that component (and component inside it) can access the value from the context.We remember that one of the cause of re-render is: 🟣 (a) parent component re-render, so if the value in context is updated, it will re-render the entire component tree that it’s wrapped.
Below is small app example where updating the context affect entire component tree it’s wrap.
import React from "react"; const ProfileContext = React.createContext(); export function App() { // 3. State change trigger "App" re-render const [count, setCount] = React.useState(0); const increase = () => { // 2. update the state setCount(data => data + 1); }; return ( <ProfileContext.Provider value={{ count, increase }}> <MyButton /> <Footer /> </ProfileContext.Provider> ); } function MyButton() { const profileContext = React.useContext(ProfileContext); const { count, increase } = profileContext; // 1. Click the button to increase count return <button onClick={increase}>Increase {count}</button>; } function Footer() { // 4. "Footer" re-render, because the "App" (it's parent) re-render // eventhough "Footer" don't consume the context :( return <div>Footer</div>; }
Then what if we want to have the global state?
We can use another state management like zustand because no need to wrap any parent component. Or if it’s async data, I think it’s better we use Tanstack Query.
It will REDUCE the re-render to only happened on component that consume it instead of the entire tree.
Below is example how using
zustand can reduce re-render.import { create } from "zustand"; // we use old version 2.2.0 here const [useCount] = create((set) => ({ count: 1, // 3. Count is increased increase: () => set((state) => ({ count: state.count + 1 })), })); export function App() { return ( <> <MyButton /> <Footer /> </> ); } function MyButton() { const count = useCount((store) => store.count); const increase = useCount((store) => store.increase); // 1. Click the button to increase count return <button onClick={increase}>Increase {count}</button>; } function Footer() { console.log("Footer"); // 3. Footer not re-render anymore :) // because Footer don't consume the context // and it's parent no longer wrapped by context return <div>Footer</div>; }
6. Use React Compiler
As stated in React docs:
…it automatically optimizes your React application by handling memoization for you, eliminating the need for manualuseMemo,useCallback, andReact.memo
This is useful because
- eliminate all of the manual memoizing methods that we describe previously, so this will fix all those three issue 🟣 🟠 🔴
- it’s know what we did wrong in our memoization and fix it
- help us to focus on building features
Basically it’s a plugin that you installed in your React project, so when your React code transpiled it will automatically wrap our code with those 3 function.
For React 19, you can use it by following the instruction here.
For React 17 and 18, see the instruction here.
Now we know why re-render can happened and how to prevent it.
But the problem is we should put
console.log everywhere to know what component did re-render.And even though we know what component did re-render, how we know what the cause of that re-render?
Fortunately, we can use tool called react-scan.
Let’s discuss it in another time.
BONUS: Another good read about this topic
