React Optimization: The Why and How
I have been working with with React and Next.js for a years, and during this time, I have learned a lot about optimizing React applications for performance. In this blog post, I will try to share some of the key strategies and techniques that I have found to be effective in improving the performance of React applications.
Why do we need optimization?
React's fundamental model is simple: When state changes, React re-renders the component tree to reflect those changes. While this model is beautiful and flexible but can be inefficent.
The Render Cycle
function ParnetComponent() {
const [count, setCount] = useState(0);
// Every time parent component re-renders, all child components also re-render
// even though it doesn't use the changed state. The `count` state is not used in ChildComponent1 and ChildComponent2
return (
<div>
<ChildComponent1 />
<ChildComponent2 />
<button onClick={() => setCount(count + 1)}>Increment</button>
</div>
);
}
Why is this bad?
- When
countchanges,ParnetComponentre-renders. - React sees that
ChildComponent1andChildComponent2are children ofParnetComponent, so it re-renders them too. - Even though nothing about
ChildComponent1orChildComponent2has changed, they still get re-rendered. - if these child components handling a list of 1000s of items, this can lead to significant performance issues.
The Memory Leak Problem
function BadComponent() {
const [data, setData] = useState([]);
useEffect(() => {
const interval = setInterval(() => {
fetchData().then((newData) => {
setData(newData);
});
}, 1000);
// PROBLEM: When components unmounts, the interval is still running
// This can lead to memory leaks and unexpected behavior
}, []);
return <div>{data.length}</div>;
}
Why does this leak?
- Components unmounts(user navigates away)
- Interval keeps running in the background
- Every second it tries to update the state of a dead component
- Event listenrs, subscribers, timers pile up in memory
- Memory usage increases over time
React.Memo - Preventing Unnecessary Rerenders
React memo is shallow comparison. It asks "Did my props changes"? if no skip re-rendering.
// WITHOUT memo -> re-render every time parent renders
function ExpensiveComponent({ data }) {
// Some expensive rendering logic
return <div>{data}</div>;
}
// Here , ExpensiveComponent will re-render every time ParnetComponent re-renders
function ParnetComponent(data) {
const [count, setCount] = useState(0);
return (
<div>
<ExpensiveComponent data={data} />
<button onClick={() => setCount(count + 1)}>Increment</button>
</div>
);
}
// WITH memo -> re-render only when props change
const ExpensiveComponent = React.memo(ExpensiveComponent);
The hidden pitfall of React.memo
function ParnetComponent() {
const [count, setCount] = useState(0);
const data = { value: "Some data" }; // New object on every render
return (
<div>
<ExpensiveComponent data={data} />{" "}
{/* Always re-renders because 'data' is a new object */}
<button onClick={() => setCount(count + 1)}>Increment</button>
</div>
);
}
Why does this happen?
- Everytime ParnetComponent renders, a new object is created for
data. - React.memo does a shallow comparison, sees that
datais a new object (different reference), and re-rendersExpensiveComponent. - To fix this, make the data stable using
useMemoor move it outside the component.
useMemo - Expesnive calculations caching
function ProductList({ product, filter }) {
// This runs every time component renders
const filtered = product.filter((p) => p.category === filter);
return (
<div>
{filtered.map((p) => (
<ProductItem key={p.id} product={p} />
))}
</div>
);
}
Why this is bad?
- Parent re-renders for unrelated reasons (like state change)
ProductListre-renders- Filters 10,000 products, sorts them, slices - wasted works
- Same input, same output, but we calculated again
The fix with useMemo
function ProductList({ product, filter }) {
// Memoize the filtered products
const filtered = useMemo(() => {
return product.filter((p) => p.category === filter);
}, [product, filter]); // Recompute only when product or filter changes
return (
<div>
{filtered.map((p) => (
<ProductItem key={p.id} product={p} />
))}
</div>
);
}
useCallback - Stable function references
function Parent() {
const [count, setCount] = useState(0);
// NEW function created on every render
const handleClick = () => {
console.log("Clicked");
};
return (
<div>
<Child onClick={handleClick} />{" "}
{/* child re-renders every time because handleClick is a new function, handleClick changes when count changes */}
<p onClick={() => setCount(count + 1)}>Count: {count}</p>
</div>
);
}
const Child = React.memo(({ onClick }) => {
console.log("Child rendered");
return <button onClick={onClick}>Click Me</button>;
});
Why does this re-render?
handleClickis a new function every render- React.memo sees
onClickprop changed (new function reference) - Child re-renders unnecessarily
The fix with useCallback
function Parent() {
const [count, setCount] = useState(0);
const handleClick = useCallback(() => {
console.log("Clicked");
}, []);
return (
<div>
<Child onClick={handleClick} />
{/* Child re-renders only when handleClick changes */}
<p onClick={() => setCount(count + 1)}>Count: {count}</p>
</div>
);
}
Why does this re-render?
handleClickis memoized withuseCallback, so its reference stays the same across renders.- New function = re-render, same function = skip re-render.
- React.memo sees
onClickprop unchanged, so Child skips re-rendering. - Child only re-renders if
handleClickchanges (which it won't, because of the empty dependency array).
The Colsure Trap
function Counter() {
const [count, setCount] = useState(0);
const logCount = useCallback(() => {
console.log("Count is:", count); // 'count' is captured from initial render
}, []); // Empty dependency array
return (
<div>
<p>Count: {count}</p>
<button onClick={() => setCount(count + 1)}>Increment</button>
<button onClick={logCount}>Log Count</button>
</div>
);
}
// GOOD: Fixing the closure trap
const handleClick = useCallback(() => {
console.log("Count is:", count);
}, [count]); // Now 'count' is a dependency
Proper useEffect Cleanup
// LEAK 1: Event listeners
function BadComponent() {
useEffect(() => {
const handleScroll = () => console.log("Scrolling");
window.addEventListener("scroll", handleScroll);
// LEAK: Listener never removed!
}, []);
}
// FIX
function GoodComponent() {
useEffect(() => {
const handleScroll = () => console.log("Scrolling");
window.addEventListener("scroll", handleScroll);
return () => {
window.removeEventListener("scroll", handleScroll); // Cleanup!
};
}, []);
}
// LEAK 2: Timers
function BadTimer() {
useEffect(() => {
const timer = setInterval(() => console.log("Tick"), 1000);
// LEAK: Timer runs forever!
}, []);
}
// FIX
function GoodTimer() {
useEffect(() => {
const timer = setInterval(() => console.log("Tick"), 1000);
return () => clearInterval(timer); // Cleanup!
}, []);
}
// LEAK 3: Subscriptions
function BadSubscription() {
useEffect(() => {
const subscription = dataStream.subscribe((data) => {
console.log(data);
});
// LEAK: Subscription active forever!
}, []);
}
// FIX
function GoodSubscription() {
useEffect(() => {
const subscription = dataStream.subscribe((data) => {
console.log(data);
});
return () => subscription.unsubscribe(); // Cleanup!
}, []);
}
State Management Optimization
// ❌ WORSE: Multiple state updates cause multiple re-renders
function BadForm() {
const [name, setName] = useState("");
const [email, setEmail] = useState("");
const [age, setAge] = useState(0);
const handleSubmit = () => {
setName("John"); // Re-render 1
setEmail("j@example.com"); // Re-render 2
setAge(30); // Re-render 3
};
return <form>...</form>;
}
// ✅ BETTER: Single state object = single re-render
function GoodForm() {
const [formData, setFormData] = useState({
name: "",
email: "",
age: 0,
});
const handleSubmit = () => {
setFormData({
name: "John",
email: "j@example.com",
age: 30,
}); // Single re-render!
};
return <form>...</form>;
}
Derived State - Avoiding Redundant State
// ❌ WORSE: Storing derived data in state
function BadCart() {
const [items, setItems] = useState([]);
const [total, setTotal] = useState(0); // Redundant!
const addItem = (item) => {
const newItems = [...items, item];
setItems(newItems);
setTotal(newItems.reduce((sum, i) => sum + i.price, 0)); // Can get out of sync!
};
return <div>Total: ${total}</div>;
}
// ✅ BETTER: Calculate on render
function GoodCart() {
const [items, setItems] = useState([]);
const total = items.reduce((sum, i) => sum + i.price, 0); // Always correct!
const addItem = (item) => {
setItems([...items, item]);
};
return <div>Total: ${total}</div>;
}
// ✅ BEST: Memoize if expensive
function BestCart() {
const [items, setItems] = useState([]);
const total = useMemo(
() => items.reduce((sum, i) => sum + i.price, 0),
[items],
);
return <div>Total: ${total}</div>;
}
Event Handlers
// ❌ WORSE: Inline arrow functions break memo
function BadList({ items }) {
return (
<div>
{items.map((item) => (
<MemoizedItem
key={item.id}
item={item}
onClick={() => console.log(item.id)} // NEW function every render!
/>
))}
</div>
);
}
// ✅ BETTER: Single handler with event delegation
function GoodList({ items }) {
const handleClick = useCallback((id) => {
console.log(id);
}, []);
return (
<div>
{items.map((item) => (
<MemoizedItem
key={item.id}
item={item}
onClick={handleClick}
id={item.id}
/>
))}
</div>
);
}
Component Organization Pattern
// Consistent component structure
import React, { useState, useEffect, useMemo, useCallback } from "react";
import PropTypes from "prop-types";
/**
* UserProfile - Displays user information
* @param {Object} user - User data object
* @param {Function} onUpdate - Called when user updates profile
*/
function UserProfile({ user, onUpdate }) {
// 1. State declarations
const [isEditing, setIsEditing] = useState(false);
// 2. Computed values (useMemo)
const displayName = useMemo(() => {
return `${user.firstName} ${user.lastName}`;
}, [user.firstName, user.lastName]);
// 3. Effects
useEffect(() => {
// Effect logic
return () => {
// Cleanup
};
}, []);
// 4. Event handlers (useCallback)
const handleEdit = useCallback(() => {
setIsEditing(true);
}, []);
// 5. Render
return (
<div>
{displayName}
<button onClick={handleEdit}>Edit</button>
</div>
);
}
// 6. PropTypes
UserProfile.propTypes = {
user: PropTypes.shape({
firstName: PropTypes.string.isRequired,
lastName: PropTypes.string.isRequired,
}).isRequired,
onUpdate: PropTypes.func,
};
// 7. Default props
UserProfile.defaultProps = {
onUpdate: () => {},
};
export default React.memo(UserProfile);
Custom Hooks Pattern
/**
* Fetch data from API with loading and error states
*/
function useApi(url) {
const [data, setData] = useState(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
useEffect(() => {
let cancelled = false; // Prevent state updates after unmount
async function fetchData() {
try {
setLoading(true);
const response = await fetch(url);
const json = await response.json();
if (!cancelled) {
setData(json);
setError(null);
}
} catch (err) {
if (!cancelled) {
setError(err.message);
}
} finally {
if (!cancelled) {
setLoading(false);
}
}
}
fetchData();
return () => {
cancelled = true; // Cleanup flag
};
}, [url]);
return { data, loading, error };
}
// Usage
function UserList() {
const { data, loading, error } = useApi('/api/users');
if (loading) return <div>Loading...</div>;
if (error) return <div>Error: {error}</div>;
return <div>{data.map(...)}</div>;
}
Final Thoughts
I hope these strategies and techniques help you optimize your React applications for better performance. Remember, optimization is an ongoing process, and it's essential to profile and measure performance regularly to identify bottlenecks specific to your application. Happy coding!