As React developers, we know React is fast. But sometimes, React can be too willing to re-render components that haven’t really changed. In this post, we’ll use a basic example to explore when and why re-renders happen, and how React.memo()
helps avoid them.
A simple Component called App with 2 state variables
import { useEffect, useState } from 'react'
function App() {
const [ count, setCount ] = useState( 0 );
const [ message, setMessage ] = useState( 'hello' );
useEffect( () => {
const timeoutId = setTimeout( () => {
setMessage( 'bye' );
}, 2000 );
return () => clearTimeout( timeoutId );
}, [] );
return <CounterDisplay count={ count } />
}
Code language: JavaScript (javascript)
Here, we have two pieces of state: count
and message
. The count
is passed to a child component, CounterDisplay
, while message
is used only inside App.
setCount
is intentionally not used
The count
state exists, but we never update it. The only change in this app is setMessage
, which updates the message
state from “hello” to “bye” two seconds after the <App/>
component mounts.
What does “re-rendering” mean?
In React, re-rendering means the component function is called again. React runs the component from top to bottom, regenerating the JSX tree based on the latest state and props. This does not necessarily mean that React updates the actual DOM, thanks to its virtual DOM diffing – but it still does the work of re-evaluating the component function.
Who triggers re-renders?
Re-renders happen when:
- A component’s internal state is updated (e.g. via
useState
() etc.). - A component receives new props.
If a state variable changes, children re-render
According to React’s default behavior, when a component’s state updates, all of its child components re-render, even if the props passed to them haven’t changed.
If a prop on the child changes, the child re-renders
This is more straightforward: if a child component receives a prop with a new value, React will re-render that child.
In our example, what causes the re-render?
Two seconds after the App component mounts, setMessage( 'bye' )
is triggered. This updates the message state inside App, which causes App to re-render. That’s expected.
But here’s the catch: React also re-renders <CounterDisplay/>
, even though the count
prop hasn’t changed. The value is still 0, and setCount
was never called.
This happens because React doesn’t know whether the child component depends on the changed state or not – so it just re-renders all children by default.
Imagine if CounterDisplay
was expensive
What if <CounterDisplay/>
was a complex component that rendered charts, graphs, or heavy UI? Even though nothing about it has changed, React still spends time and memory re-rendering it. That’s wasteful.
This is where React.memo()
comes in
React gives us a tool: React.memo()
. It’s a higher-order component that wraps around another component and tells React:
“Only re-render this component if its props have changed.”
Let’s update <CounterDisplay/>
to use it:
const CounterDisplay = React.memo( function CounterDisplay( { count } ) {
console.log( 'Rendering CounterDisplay' );
return <div>Count: { count }</div>;
} );
Code language: JavaScript (javascript)
Now, even if App
re-renders because of a message
state change, CounterDisplay
will not re-render as long as count
hasn’t changed.
How does React.memo()
work?
It does a shallow comparison of the props:
- For primitive values (like numbers and strings), React checks if the new value is
===
to the old one. - For objects and functions, React checks if the reference is the same.
So when does it not work?
Let’s say App
defines a non-primitive value, like a function or an object, and passes it to CounterDisplay
as a prop. The key detail here is – a non-primitive value – created inside App
.
import { useEffect, useState } from 'react'
function App() {
const [ count, setCount ] = useState( 0 )
const [ message, setMessage ] = useState( 'hello' )
useEffect( () => {
const timeoutId = setTimeout( () => {
setMessage( 'bye' );
}, 2000 );
return () => clearTimeout( timeoutId )
}, [] );
function greet() {}
// Note: CounterDisplay is memoized using React.memo()
return <CounterDisplay count={ count } greet={ greet } />
}
Code language: JavaScript (javascript)
In the code above, every time the message
state updates, App re-renders and creates a new function greet()
. Since it’s a non-primitive, and is passed by reference – CounterDisplay
interprets that as a new prop, and React triggers a re-render for CounterDisplay
.
So we can’t pass setCount or setMessage as props without triggering a re-render?
setCount
and setMessage
are state setters. Even though they are functions, in React, all state setters are guaranteed to have stable references across re-renders. This allows you to pass state setters as props without triggering a re-render.
Will a function defined outside App have a stable reference?
Yes! Any non-primitive – function or an object created outside of the App
component will have a stable reference because it is defined once at the module level, not re-created on every render.
When you define something inside a component, it gets re-executed with every render. But when it’s outside, it’s part of the module scope and is initialized only once when the module is loaded. So the reference remains the same across all renders of the component.
import { useEffect, useState } from 'react'
function greet() {}
function App() {
const [ count, setCount ] = useState( 0 )
const [ message, setMessage ] = useState( 'hello' )
useEffect( () => {
const timeoutId = setTimeout( () => {
setMessage( 'bye' );
}, 2000 );
return () => clearTimeout( timeoutId )
}, [] );
// Note: CounterDisplay is memoized using React.memo()
return <CounterDisplay count={ count } greet={ greet } />
}
Code language: JavaScript (javascript)
In the code above, since function greet()
is defined outside the App Component. It does not get recreated every time React re-renders the App Component. This is applicable for all non-primitives created outside of the Component.
The code above is an example to demonstrate stable references. It makes no sense to pass something global as props when you can just access it directly inside a Component.
Is there a way to pass non-primitives with stable references?
Yes! If you need to pass a function, object, or array as a prop – and want to avoid re-renders caused by changing references – you can use React’s built-in hooks: useCallback()
for functions and useMemo()
for objects and arrays.
We won’t dive into this here and will cover it in a separate blog post.
Final Thoughts
React.memo()
is not something you need everywhere. But in performance-critical components, or when dealing with deeply nested trees, it can prevent unnecessary re-renders and save resources.
Just remember:
- React re-renders children by default, even if their props haven’t changed.
React.memo()
skips re-renders based on shallow prop comparison.- It works best with primitive props, or memoized non-primitives.
- It doesn’t come with zero cost – it adds an extra layer of comparison – so use it only where it provides a real benefit.
Understanding React.memo()
helps you write more efficient and intentional React code. Not everything needs to be memoized, but the parts that do will thank you for it.
Leave a Reply