Fixing React Performance: Understanding Re-renders
Introduction
Imagine this: you’re a developer who just joined a team working on a large, complicated, and performance-sensitive React app. On your very first task, your manager asks you to add a simple button to the header. The button should open a modal dialog.
You code it up, test it, and… it works! But there’s a catch — it takes a full second for the dialog to appear after clicking the button.
That’s slow.
Why is this happening? More importantly, how do you fix it?
In this advanced React guide, we’ll tackle one of the most common performance problems in React — unnecessary re-renders.
What You’ll Learn:
- What are React re-renders and how do they work?
- The biggest myth about props and re-renders
- Why custom hooks can be misleading for performance
- A simple trick to fix slow UI updates
The Problem: A Familiar Pattern, A Common Pitfall
You have a typical React App component rendering many heavy child components. You introduce state to handle the modal visibility:
const [showModal, setShowModal] = useState(false);
You add a button that toggles this state, and a modal component that appears when showModal is true. From the code perspective, this looks straightforward — something we do daily.
Let’s see a complete example of this pattern:
import React, { useState } from "react";
import {
HeavyComponent1,
HeavyComponent2,
HeavyComponent3,
} from "./components";
import Modal from "./components/Modal";
function App() {
// Modal visibility state at the App level
const [showModal, setShowModal] = useState(false);
return (
<div className="app">
<header className="header">
<h1>My App</h1>
<button className="open-modal-btn" onClick={() => setShowModal(true)}>
Open Modal
</button>
</header>
{/* These heavy components re-render when modal state changes */}
<HeavyComponent1 />
<HeavyComponent2 />
<HeavyComponent3 />
{showModal && <Modal onClose={() => setShowModal(false)} />}
</div>
);
}
export default App;
Yet, clicking the button causes the entire app to lag for a second. Why?
Understanding Mounting, Unmounting, and Re-rendering
Before we fix anything, let’s refresh how React updates the UI:
Mounting: React creates a component for the first time, sets up state, runs hooks, and adds DOM elements.
Unmounting: React destroys the component, cleans up state and removes its DOM elements.
Re-rendering: React updates an existing component with new data, reuses existing structures, and patches the DOM efficiently.
Every re-render starts with a state update. When state changes, the component holding that state re-renders — along with all its children, recursively.
In our case, when showModal changes, the App component (and everything inside it) re-renders, including heavy, expensive components. That’s where the 1-second lag comes from.
Here’s a simplified example showing how React’s re-rendering works internally:
// This is pseudo-code to illustrate the concept
function renderComponent(component) {
if (component.stateChanged) {
// Re-render the component itself
component.render();
// Re-render all children recursively
component.children.forEach((child) => {
renderComponent(child);
});
}
}
The Hidden Danger of Custom Hooks
To organize state logic, you might think of moving modal-related state into a custom hook:
const { showModal, toggleModal } = useModal();
Here’s what that custom hook might look like:
// useModal.js
import { useState } from "react";
export function useModal() {
const [showModal, setShowModal] = useState(false);
const openModal = () => setShowModal(true);
const closeModal = () => setShowModal(false);
const toggleModal = () => setShowModal((prev) => !prev);
return {
showModal,
openModal,
closeModal,
toggleModal,
};
}
And using it in your App component:
import React from "react";
import {
HeavyComponent1,
HeavyComponent2,
HeavyComponent3,
} from "./components";
import Modal from "./components/Modal";
import { useModal } from "./hooks/useModal";
function App() {
// Using our custom hook
const { showModal, openModal, closeModal } = useModal();
return (
<div className="app">
<header className="header">
<h1>My App</h1>
<button className="open-modal-btn" onClick={openModal}>
Open Modal
</button>
</header>
{/* These still re-render when modal state changes! */}
<HeavyComponent1 />
<HeavyComponent2 />
<HeavyComponent3 />
{showModal && <Modal onClose={closeModal} />}
</div>
);
}
export default App;
Seems cleaner, right? But there’s a catch.
Even though state is now “hidden” inside a hook, it still resides in the component where the hook is used. Updating the state still triggers a re-render of the entire App component and its children.
Hooks don’t magically isolate state from re-renders. Moving state into a hook might hide complexity in code, but performance-wise, the component behaves the same.
Debunking the Props Myth
There’s a persistent myth in the React community:
“Props changes cause re-renders.”
That’s not entirely accurate.
React doesn’t watch for prop changes outside of a re-render cycle.
If no state update triggers a re-render, changing a prop value alone does nothing.
Props only “matter” when a re-render is already happening.
Let’s see an example:
import React, { useState, useRef } from "react";
function Child({ value }) {
console.log("Child rendered with value:", value);
return <div>Child: {value}</div>;
}
function Parent() {
const [, forceUpdate] = useState({});
const valueRef = useRef(0);
const updatePropWithoutRerender = () => {
// This changes the prop value but doesn't trigger a re-render
valueRef.current += 1;
console.log("Prop changed to:", valueRef.current);
// Child component will NOT re-render!
};
const updatePropWithRerender = () => {
// This changes the prop value AND triggers a re-render
valueRef.current += 1;
forceUpdate({});
// Child component WILL re-render!
};
return (
<div>
<Child value={valueRef.current} />
<button onClick={updatePropWithoutRerender}>
Change Prop (No Re-render)
</button>
<button onClick={updatePropWithRerender}>
Change Prop (With Re-render)
</button>
</div>
);
}
The exception: components wrapped in React.memo, which can prevent unnecessary renders based on prop comparisons — but that’s a deeper topic for another time.
// Example of React.memo preventing re-renders
import React, { useState, memo } from "react";
// This component will only re-render if its props change
const MemoizedChild = memo(function Child({ value }) {
console.log("Child rendered with value:", value);
return <div>Child: {value}</div>;
});
function Parent() {
const [count, setCount] = useState(0);
const [childValue, setChildValue] = useState(0);
return (
<div>
<button onClick={() => setCount((c) => c + 1)}>
Increment Parent Counter: {count}
</button>
<button onClick={() => setChildValue((v) => v + 1)}>
Update Child Value
</button>
{/* This will NOT re-render when 'count' changes, only when 'childValue' changes */}
<MemoizedChild value={childValue} />
</div>
);
}
The Simple Fix: Move State Down
Here’s the good news: fixing this performance bottleneck is surprisingly simple.
The solution is called “moving state down”.
How it works:
- Isolate the modal state into a smaller child component
- Move the button and modal rendering into that child component
- Render this smaller component from the App
Example:
function ModalController() {
const [showModal, setShowModal] = useState(false);
return (
<>
<button onClick={() => setShowModal(true)}>Open Modal</button>
{showModal && <Modal />}
</>
);
}
function App() {
return (
<div>
<HeavyComponents />
<ModalController />
</div>
);
}
Let’s see a more complete version of this solution:
import React, { useState } from "react";
import {
HeavyComponent1,
HeavyComponent2,
HeavyComponent3,
} from "./components";
import Modal from "./components/Modal";
// Modal logic isolated in its own component
function ModalController() {
const [showModal, setShowModal] = useState(false);
return (
<>
<button className="open-modal-btn" onClick={() => setShowModal(true)}>
Open Modal
</button>
{showModal && <Modal onClose={() => setShowModal(false)} />}
</>
);
}
function App() {
return (
<div className="app">
<header className="header">
<h1>My App</h1>
<ModalController />
</header>
{/* These heavy components no longer re-render when modal state changes */}
<HeavyComponent1 />
<HeavyComponent2 />
<HeavyComponent3 />
</div>
);
}
export default App;
Now, clicking the button only re-renders the small ModalController component. The heavy App components stay untouched, and your modal opens instantly.
You can also use Context API for components that need to access the same state but are located in different parts of the component tree:
import React, { createContext, useContext, useState } from "react";
// Create context
const ModalContext = createContext();
// Provider component that wraps only the components that need modal state
function ModalProvider({ children }) {
const [showModal, setShowModal] = useState(false);
const value = {
showModal,
openModal: () => setShowModal(true),
closeModal: () => setShowModal(false),
toggleModal: () => setShowModal((prev) => !prev),
};
return (
<ModalContext.Provider value={value}>{children}</ModalContext.Provider>
);
}
// Custom hook for consuming the context
function useModalContext() {
const context = useContext(ModalContext);
if (context === undefined) {
throw new Error("useModalContext must be used within a ModalProvider");
}
return context;
}
// Components that use the modal state
function OpenModalButton() {
const { openModal } = useModalContext();
return <button onClick={openModal}>Open Modal</button>;
}
function ModalContainer() {
const { showModal, closeModal } = useModalContext();
return showModal ? <Modal onClose={closeModal} /> : null;
}
// Main app component
function App() {
return (
<div className="app">
<HeavyComponent1 />
<HeavyComponent2 />
{/* Only these wrapped components re-render when modal state changes */}
<ModalProvider>
<OpenModalButton />
<ModalContainer />
</ModalProvider>
<HeavyComponent3 />
</div>
);
}
Conclusion
Understanding how React re-renders work is crucial for building high-performance apps. The key takeaways from this guide are:
- Re-renders are triggered by state updates, not props.
- Hooks don’t isolate state from re-renders — they just hide complexity.
- The most effective fix is often as simple as moving state closer to where it’s used.
In future guides, we’ll dive deeper into how React triggers re-renders, why “children are not always children”, and more performance pitfalls to avoid.