Introduction to useMemo and useCallback
In our previous lecture, we explored how React.memo can prevent unnecessary component re-renders. However, React.memo only works effectively when the component's props maintain stable references across renders. This is where useMemo and useCallback come into play.
These hooks help solve two common performance issues in React applications:
- Expensive calculations being recomputed on every render
- New function instances being created on every render
Let's understand what each hook does:
- useMemo: Memoizes the result of a calculation, recomputing it only when dependencies change
- useCallback: Memoizes a function instance, creating a new function only when dependencies change
Real-World Analogy: Caching vs. Recalculating
Imagine you're a math teacher grading tests:
- Without memoization: You recalculate every student's test score from scratch each time you need to refer to it
- With memoization: You calculate each score once, write it down, and simply refer to your notes when you need the score again
Just as it would be inefficient to recalculate every score repeatedly, React applications can waste resources by recomputing values or recreating functions unnecessarily.
The useMemo Hook
The useMemo hook memoizes the result of a computation, only recomputing when one of its dependencies changes.
Basic Syntax
const memoizedValue = useMemo(() => {
// Perform expensive calculation
return computeExpensiveValue(a, b);
}, [a, b]); // Dependencies array
The useMemo hook takes two arguments:
- A create function that computes a value
- A dependencies array that determines when to recompute the value
During the initial render, useMemo calls the create function and returns its result. On subsequent renders, it will either return the cached result (if dependencies haven't changed) or recompute and cache a new result (if dependencies have changed).
Memoizing Expensive Calculations
The primary use case for useMemo is to avoid repeating expensive calculations when unnecessary:
import React, { useState, useMemo } from 'react';
function ExpensiveCalculation({ data, threshold }) {
// This calculation will only run when data or threshold changes
const processedData = useMemo(() => {
console.log('Processing data...');
// Simulate expensive calculation
return data.filter(item => {
// Imagine this is a complex calculation
let result = 0;
for (let i = 0; i < 10000; i++) {
result += Math.sqrt(item.value * i);
}
return result > threshold;
});
}, [data, threshold]);
return (
<div>
<h2>Processed Data</h2>
<p>Found {processedData.length} items above threshold.</p>
<ul>
{processedData.map(item => (
<li key={item.id}>
{item.name}: {item.value}
</li>
))}
</ul>
</div>
);
}
Memoizing Objects to Stabilize Props
Another common use for useMemo is to prevent new object references from causing unnecessary re-renders of memoized child components:
function UserProfile({ userId }) {
const [user, setUser] = useState(null);
const [loading, setLoading] = useState(true);
// Fetch user data when userId changes
useEffect(() => {
setLoading(true);
fetchUser(userId).then(data => {
setUser(data);
setLoading(false);
});
}, [userId]);
// Create a stable userInfo object that only changes when user changes
const userInfo = useMemo(() => {
if (!user) return null;
return {
displayName: `${user.firstName} ${user.lastName}`,
email: user.email,
role: user.role,
joinDate: new Date(user.joinDate).toLocaleDateString()
};
}, [user]);
if (loading) return <div>Loading...</div>;
if (!user) return <div>User not found</div>;
return (
<div>
{/* UserInfo will only re-render when the userInfo object changes */}
<UserInfo info={userInfo} />
<UserActivity userId={userId} />
</div>
);
}
// Memoized child component
const UserInfo = React.memo(function UserInfo({ info }) {
console.log('UserInfo rendered');
if (!info) return null;
return (
<div className="user-info">
<h2>{info.displayName}</h2>
<p>Email: {info.email}</p>
<p>Role: {info.role}</p>
<p>Joined: {info.joinDate}</p>
</div>
);
});
Real-World Example: Data Visualization
Data visualization components often perform complex calculations to transform raw data into visualizable formats. Using useMemo can significantly improve performance:
function DataVisualization({ rawData, filters, dimensions }) {
// Memoize the complex data processing
const processedData = useMemo(() => {
console.log('Processing visualization data...');
// 1. Filter data based on criteria
const filteredData = rawData.filter(item => {
return Object.entries(filters).every(([key, value]) => {
// Skip empty filters
if (!value) return true;
return item[key] === value;
});
});
// 2. Group and aggregate data
const groupedData = filteredData.reduce((acc, item) => {
const key = item.category;
if (!acc[key]) {
acc[key] = {
category: key,
count: 0,
total: 0,
items: []
};
}
acc[key].count += 1;
acc[key].total += item.value;
acc[key].items.push(item);
return acc;
}, {});
// 3. Convert to array and calculate averages
const result = Object.values(groupedData).map(group => ({
...group,
average: group.total / group.count
}));
// 4. Sort by specified criteria
return result.sort((a, b) => b.total - a.total);
}, [rawData, filters]); // Only recalculate when data or filters change
// Memoize chart configuration
const chartConfig = useMemo(() => {
return {
width: dimensions.width,
height: dimensions.height,
margin: { top: 20, right: 30, bottom: 40, left: 50 },
colors: ['#ff6b6b', '#48dbfb', '#1dd1a1', '#5f27cd']
};
}, [dimensions]); // Only recalculate when dimensions change
return (
<div className="visualization-container">
<h2>Data Visualization</h2>
<div className="chart-area">
<BarChart
data={processedData}
config={chartConfig}
/>
</div>
<div className="data-summary">
<h3>Summary</h3>
<p>Total categories: {processedData.length}</p>
<p>Total items: {processedData.reduce((sum, group) => sum + group.count, 0)}</p>
<p>Highest value: {processedData[0]?.total || 0}</p>
</div>
</div>
);
}
// Memoized chart component
const BarChart = React.memo(function BarChart({ data, config }) {
console.log('BarChart rendering');
// Chart rendering logic...
return <svg width={config.width} height={config.height}>{/* Chart elements */}</svg>;
});
In this example, both the data processing and chart configuration are memoized to prevent unnecessary recalculations when the component re-renders due to unrelated state changes.
The useCallback Hook
The useCallback hook memoizes a function definition, only creating a new function when dependencies change.
Basic Syntax
const memoizedCallback = useCallback(() => {
// Function logic
doSomething(a, b);
}, [a, b]); // Dependencies array
The useCallback hook takes two arguments:
- A function to memoize
- A dependencies array that determines when to recreate the function
The key difference between useMemo and useCallback is:
useMemomemoizes the result of a function calluseCallbackmemoizes the function itself
// These are equivalent:
const memoizedFunction = useCallback(() => {
return doSomething(a, b);
}, [a, b]);
const memoizedFunction = useMemo(() => {
return () => {
return doSomething(a, b);
};
}, [a, b]);
Stabilizing Event Handlers
The primary use case for useCallback is to prevent new function instances from causing child component re-renders:
function TodoList() {
const [todos, setTodos] = useState([]);
const [newTodo, setNewTodo] = useState('');
// This function will be recreated only when setTodos changes
// (which never happens since it's from useState)
const handleAddTodo = useCallback(() => {
if (newTodo.trim()) {
setTodos([...todos, { id: Date.now(), text: newTodo, completed: false }]);
setNewTodo('');
}
}, [newTodo, todos, setTodos]);
// This function will be recreated only when todos changes
const handleToggleTodo = useCallback((id) => {
setTodos(todos.map(todo =>
todo.id === id ? { ...todo, completed: !todo.completed } : todo
));
}, [todos]);
// This function will be recreated only when todos changes
const handleDeleteTodo = useCallback((id) => {
setTodos(todos.filter(todo => todo.id !== id));
}, [todos]);
return (
<div>
<h2>Todo List</h2>
<div>
<input
type="text"
value={newTodo}
onChange={e => setNewTodo(e.target.value)}
placeholder="Add a new todo"
/>
<button onClick={handleAddTodo}>Add</button>
</div>
<ul>
{todos.map(todo => (
<TodoItem
key={todo.id}
todo={todo}
onToggle={handleToggleTodo}
onDelete={handleDeleteTodo}
/>
))}
</ul>
</div>
);
}
// Memoized todo item to prevent unnecessary re-renders
const TodoItem = React.memo(function TodoItem({ todo, onToggle, onDelete }) {
console.log(`TodoItem rendered: ${todo.text}`);
return (
<li style={{ textDecoration: todo.completed ? 'line-through' : 'none' }}>
<input
type="checkbox"
checked={todo.completed}
onChange={() => onToggle(todo.id)}
/>
<span>{todo.text}</span>
<button onClick={() => onDelete(todo.id)}>Delete</button>
</li>
);
});
Optimizing Event Handler Parameters
A common pattern is to define handlers that take parameters. useCallback helps maintain stable references:
function ShoppingCart({ items, onUpdateQuantity, onRemoveItem }) {
return (
<div className="shopping-cart">
{items.map(item => (
<CartItem
key={item.id}
item={item}
onUpdateQuantity={onUpdateQuantity}
onRemoveItem={onRemoveItem}
/>
))}
</div>
);
}
function CartPage() {
const [cart, setCart] = useState([
{ id: 1, name: 'Product 1', price: 10, quantity: 1 },
{ id: 2, name: 'Product 2', price: 20, quantity: 2 }
]);
// Memoize the handlers to maintain stable references
const handleUpdateQuantity = useCallback((itemId, newQuantity) => {
setCart(prevCart =>
prevCart.map(item =>
item.id === itemId
? { ...item, quantity: Math.max(1, newQuantity) }
: item
)
);
}, []);
const handleRemoveItem = useCallback((itemId) => {
setCart(prevCart => prevCart.filter(item => item.id !== itemId));
}, []);
// Derived value can be memoized with useMemo
const cartTotal = useMemo(() => {
return cart.reduce((total, item) => total + (item.price * item.quantity), 0);
}, [cart]);
return (
<div>
<h1>Shopping Cart</h1>
<ShoppingCart
items={cart}
onUpdateQuantity={handleUpdateQuantity}
onRemoveItem={handleRemoveItem}
/>
<div className="cart-summary">
<h2>Summary</h2>
<p>Total Items: {cart.length}</p>
<p>Total Quantity: {cart.reduce((sum, item) => sum + item.quantity, 0)}</p>
<p>Total Price: ${cartTotal.toFixed(2)}</p>
<button>Proceed to Checkout</button>
</div>
</div>
);
}
// Memoized cart item component
const CartItem = React.memo(function CartItem({ item, onUpdateQuantity, onRemoveItem }) {
console.log(`CartItem rendered: ${item.name}`);
const handleIncrement = () => onUpdateQuantity(item.id, item.quantity + 1);
const handleDecrement = () => onUpdateQuantity(item.id, item.quantity - 1);
const handleRemove = () => onRemoveItem(item.id);
return (
<div className="cart-item">
<div>{item.name}</div>
<div>${item.price.toFixed(2)}</div>
<div className="quantity-controls">
<button onClick={handleDecrement}>-</button>
<span>{item.quantity}</span>
<button onClick={handleIncrement}>+</button>
</div>
<div>${(item.price * item.quantity).toFixed(2)}</div>
<button onClick={handleRemove}>Remove</button>
</div>
);
});
Functional Updates with useState
Notice in the previous example how we used the functional form of setCart:
// Functional update pattern
setCart(prevCart => prevCart.map(item => ... ));
This pattern is important when using useCallback with dependencies. By using the functional update form, you can often remove dependencies from the dependency array, making your callbacks more stable:
// Without functional updates - requires 'cart' dependency
const handleUpdateQuantity = useCallback((itemId, newQuantity) => {
setCart(cart.map(item =>
item.id === itemId
? { ...item, quantity: Math.max(1, newQuantity) }
: item
));
}, [cart]); // cart must be a dependency
// With functional updates - no 'cart' dependency needed
const handleUpdateQuantity = useCallback((itemId, newQuantity) => {
setCart(prevCart => prevCart.map(item =>
item.id === itemId
? { ...item, quantity: Math.max(1, newQuantity) }
: item
));
}, []); // No dependencies!
This creates a more stable function reference that doesn't need to be recreated whenever the cart changes.
Dependencies and Optimization
Managing dependencies correctly is crucial for both useMemo and useCallback. Let's explore some best practices:
Understanding the Dependencies Array
The dependencies array works the same way as in useEffect:
- Empty array
[]: Only compute once on initial render - No array provided: Compute on every render (defeats the purpose)
- With dependencies: Compute when any dependency changes
// Compute once (on mount)
const value1 = useMemo(() => expensiveComputation(), []);
// Compute on every render (not recommended)
const value2 = useMemo(() => expensiveComputation());
// Compute when dependencies change
const value3 = useMemo(() => expensiveComputation(a, b), [a, b]);
Dependency Pitfalls
Common mistakes with dependencies can lead to unexpected behavior:
// Mistake 1: Missing dependencies
const calculateTotal = useCallback(() => {
return cart.items.reduce((sum, item) => sum + item.price * item.quantity, 0);
}, []); // ❌ Missing 'cart' dependency
// Mistake 2: Unnecessary dependencies
const formatDate = useCallback((date) => {
return new Date(date).toLocaleDateString();
}, [someUnrelatedState]); // ❌ Unnecessary dependency
// Mistake 3: Objects/arrays created inline
const sortItems = useMemo(() => {
return (items) => {
return [...items].sort((a, b) => a.name.localeCompare(b.name));
};
}, [{ key: 'value' }]); // ❌ New object on every render
Dependency Deep Dives
Let's fix the above issues:
// Fix 1: Add missing dependencies
const calculateTotal = useCallback(() => {
return cart.items.reduce((sum, item) => sum + item.price * item.quantity, 0);
}, [cart.items]); // ✅ Include all dependencies
// Better solution: Use functional updates when possible
const handleAddItem = useCallback((newItem) => {
setCart(prevCart => ({
...prevCart,
items: [...prevCart.items, newItem]
}));
}, []); // ✅ No dependencies needed
// Fix 2: Remove unnecessary dependencies
const formatDate = useCallback((date) => {
return new Date(date).toLocaleDateString();
}, []); // ✅ No external dependencies
// Fix 3: Move object creation outside or memoize it
const sortConfig = useMemo(() => ({ key: 'value' }), []);
const sortItems = useMemo(() => {
return (items) => {
return [...items].sort((a, b) => a.name.localeCompare(b.name));
};
}, [sortConfig]); // ✅ Stable dependency
When to Omit Memoization
Sometimes memoization isn't necessary:
// Don't need useMemo for simple calculations
const total = items.reduce((sum, item) => sum + item.price, 0);
// Better than useMemo for simple transformations
const upperName = name.toUpperCase();
// Don't need useCallback for simple event handlers in non-optimized components
function SimpleButton() {
const handleClick = () => {
console.log('Button clicked');
};
return <button onClick={handleClick}>Click Me</button>;
}
The ESLint Hooks Plugin
The ESLint plugin for React hooks (eslint-plugin-react-hooks) includes rules to help ensure correct dependencies:
react-hooks/exhaustive-deps: Warns when dependencies are missing
This plugin is included in Create React App and many other React setups. It's a good practice to enable it and pay attention to its warnings.
// eslintrc.js configuration
module.exports = {
plugins: [
'react-hooks'
],
rules: {
'react-hooks/rules-of-hooks': 'error',
'react-hooks/exhaustive-deps': 'warn'
}
};
The plugin will catch issues like:
// This will trigger a warning
const handleSubmit = useCallback(() => {
console.log(formData); // formData is used but not in dependencies
}, []); // ESLint will warn about missing 'formData' dependency
Advanced Patterns with useMemo and useCallback
Recursive Memoization
For complex components, you might need to memoize at multiple levels:
function ComplexForm({ initialData, onSubmit }) {
const [formData, setFormData] = useState(initialData);
// Memoize the event handlers
const handleChange = useCallback((e) => {
const { name, value } = e.target;
setFormData(prev => ({ ...prev, [name]: value }));
}, []);
const handleSubmit = useCallback((e) => {
e.preventDefault();
onSubmit(formData);
}, [formData, onSubmit]);
// Memoize derived/computed data
const isFormValid = useMemo(() => {
return Object.entries(formData).every(([key, value]) => {
if (key === 'email') {
return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value);
}
return value.trim() !== '';
});
}, [formData]);
// Memoize the form configuration
const fieldConfig = useMemo(() => ({
name: {
label: 'Full Name',
placeholder: 'Enter your full name',
required: true
},
email: {
label: 'Email Address',
placeholder: 'Enter your email',
type: 'email',
required: true
},
message: {
label: 'Message',
placeholder: 'Enter your message',
component: 'textarea',
required: true
}
}), []);
// Memoize the fields component
const Fields = useMemo(() => {
return Object.entries(fieldConfig).map(([name, config]) => (
<FormField
key={name}
name={name}
value={formData[name] || ''}
onChange={handleChange}
{...config}
/>
));
}, [fieldConfig, formData, handleChange]);
return (
<form onSubmit={handleSubmit}>
{Fields}
<button type="submit" disabled={!isFormValid}>
Submit
</button>
</form>
);
}
// Memoized form field component
const FormField = React.memo(function FormField({
name,
label,
value,
onChange,
component = 'input',
...rest
}) {
console.log(`Rendering field: ${name}`);
const inputProps = {
id: name,
name,
value,
onChange,
...rest
};
return (
<div className="form-field">
<label htmlFor={name}>{label}</label>
{component === 'textarea' ? (
<textarea {...inputProps} />
) : (
<input type="text" {...inputProps} />
)}
</div>
);
});
Custom Hooks with Memoization
Extract memoization logic into custom hooks for reusability:
// Custom hook for form field handling
function useFormField(initialValue = '') {
const [value, setValue] = useState(initialValue);
// Memoize the change handler
const handleChange = useCallback((e) => {
setValue(e.target.value);
}, []);
// Memoize the reset function
const reset = useCallback(() => {
setValue(initialValue);
}, [initialValue]);
// Return memoized values and functions
return {
value,
onChange: handleChange,
reset
};
}
// Usage in a component
function SimpleForm() {
const nameField = useFormField('');
const emailField = useFormField('');
const messageField = useFormField('');
// Memoize the submit handler
const handleSubmit = useCallback((e) => {
e.preventDefault();
// Form submission logic
console.log({
name: nameField.value,
email: emailField.value,
message: messageField.value
});
// Reset fields
nameField.reset();
emailField.reset();
messageField.reset();
}, [nameField, emailField, messageField]);
return (
<form onSubmit={handleSubmit}>
<div>
<label htmlFor="name">Name:</label>
<input
id="name"
type="text"
{...nameField}
/>
</div>
<div>
<label htmlFor="email">Email:</label>
<input
id="email"
type="email"
{...emailField}
/>
</div>
<div>
<label htmlFor="message">Message:</label>
<textarea
id="message"
{...messageField}
/>
</div>
<button type="submit">Send Message</button>
</form>
);
}
Memoization with Context
Optimize context providers and consumers with memoization:
// theme-context.js
import { createContext, useContext, useMemo, useState, useCallback } from 'react';
const ThemeContext = createContext();
export function ThemeProvider({ children }) {
const [theme, setTheme] = useState('light');
// Memoize the toggle function
const toggleTheme = useCallback(() => {
setTheme(prevTheme => prevTheme === 'light' ? 'dark' : 'light');
}, []);
// Memoize the context value
const contextValue = useMemo(() => ({
theme,
toggleTheme
}), [theme, toggleTheme]);
return (
<ThemeContext.Provider value={contextValue}>
{children}
</ThemeContext.Provider>
);
}
export function useTheme() {
return useContext(ThemeContext);
}
// Usage in components
function ThemedButton() {
const { theme, toggleTheme } = useTheme();
// The button style is memoized based on the theme
const buttonStyle = useMemo(() => ({
background: theme === 'light' ? '#ffffff' : '#333333',
color: theme === 'light' ? '#333333' : '#ffffff',
border: `1px solid ${theme === 'light' ? '#dddddd' : '#555555'}`,
padding: '8px 16px',
borderRadius: '4px'
}), [theme]);
return (
<button style={buttonStyle} onClick={toggleTheme}>
Toggle to {theme === 'light' ? 'Dark' : 'Light'} Theme
</button>
);
}
Real-World Example: Data Grid Component
Let's see how useMemo and useCallback can be used in a complex data grid component:
function DataGrid({ data, columns, sortable = true, filterable = true }) {
const [sortConfig, setSortConfig] = useState({
key: null,
direction: 'asc'
});
const [filters, setFilters] = useState({});
const [page, setPage] = useState(1);
const [rowsPerPage, setRowsPerPage] = useState(10);
// Memoize the sorted and filtered data
const processedData = useMemo(() => {
console.log('Processing grid data...');
// Apply filters
let result = [...data];
if (Object.keys(filters).length) {
result = result.filter(item => {
return Object.entries(filters).every(([key, value]) => {
if (!value) return true;
return String(item[key]).toLowerCase().includes(value.toLowerCase());
});
});
}
// Apply sorting
if (sortConfig.key) {
result.sort((a, b) => {
const valueA = a[sortConfig.key];
const valueB = b[sortConfig.key];
if (valueA < valueB) {
return sortConfig.direction === 'asc' ? -1 : 1;
}
if (valueA > valueB) {
return sortConfig.direction === 'asc' ? 1 : -1;
}
return 0;
});
}
return result;
}, [data, filters, sortConfig]);
// Memoize pagination calculations
const paginationData = useMemo(() => {
const totalItems = processedData.length;
const totalPages = Math.ceil(totalItems / rowsPerPage);
const startIndex = (page - 1) * rowsPerPage;
const endIndex = startIndex + rowsPerPage;
const currentPageData = processedData.slice(startIndex, endIndex);
return {
totalItems,
totalPages,
currentPageData
};
}, [processedData, page, rowsPerPage]);
// Memoize event handlers
const handleSort = useCallback((key) => {
setSortConfig(prevConfig => {
if (prevConfig.key === key) {
return {
key,
direction: prevConfig.direction === 'asc' ? 'desc' : 'asc'
};
}
return { key, direction: 'asc' };
});
}, []);
const handleFilter = useCallback((key, value) => {
setFilters(prevFilters => ({
...prevFilters,
[key]: value
}));
// Reset to first page when filtering
setPage(1);
}, []);
const handlePageChange = useCallback((newPage) => {
setPage(newPage);
}, []);
const handleRowsPerPageChange = useCallback((e) => {
setRowsPerPage(Number(e.target.value));
setPage(1); // Reset to first page
}, []);
// Memoize header rendering
const headerRow = useMemo(() => {
return (
<tr>
{columns.map(column => (
<th key={column.key}>
<div className="column-header">
<span>{column.label}</span>
{sortable && (
<button
className="sort-button"
onClick={() => handleSort(column.key)}
>
{sortConfig.key === column.key ? (
sortConfig.direction === 'asc' ? '▲' : '▼'
) : '⇵'}
</button>
)}
</div>
{filterable && (
<input
type="text"
className="column-filter"
placeholder={`Filter ${column.label}`}
value={filters[column.key] || ''}
onChange={e => handleFilter(column.key, e.target.value)}
/>
)}
</th>
))}
</tr>
);
}, [columns, sortable, filterable, sortConfig, filters, handleSort, handleFilter]);
return (
<div className="data-grid-container">
{/* Grid controls */}
<div className="grid-controls">
<div>
<span>Rows per page: </span>
<select
value={rowsPerPage}
onChange={handleRowsPerPageChange}
>
<option value="5">5</option>
<option value="10">10</option>
<option value="25">25</option>
<option value="50">50</option>
</select>
</div>
<div>
<span>
Showing {(page - 1) * rowsPerPage + 1} to {
Math.min(page * rowsPerPage, paginationData.totalItems)
} of {paginationData.totalItems} items
</span>
</div>
</div>
{/* Data table */}
<table className="data-grid">
<thead>
{headerRow}
</thead>
<tbody>
{paginationData.currentPageData.map(item => (
<tr key={item.id}>
{columns.map(column => (
<td key={`${item.id}-${column.key}`}>
{column.render
? column.render(item[column.key], item)
: item[column.key]}
</td>
))}
</tr>
))}
</tbody>
</table>
{/* Pagination */}
<div className="pagination">
<button
disabled={page === 1}
onClick={() => handlePageChange(page - 1)}
>
Previous
</button>
{Array.from({ length: paginationData.totalPages }, (_, i) => i + 1)
.filter(p => Math.abs(p - page) < 3 || p === 1 || p === paginationData.totalPages)
.map((p, i, arr) => {
// Add ellipsis if needed
if (i > 0 && p > arr[i - 1] + 1) {
return (
<React.Fragment key={`ellipsis-${p}`}>
<span className="ellipsis">...</span>
<button
className={p === page ? 'active' : ''}
onClick={() => handlePageChange(p)}
>
{p}
</button>
</React.Fragment>
);
}
return (
<button
key={p}
className={p === page ? 'active' : ''}
onClick={() => handlePageChange(p)}
>
{p}
</button>
);
})}
<button
disabled={page === paginationData.totalPages}
onClick={() => handlePageChange(page + 1)}
>
Next
</button>
</div>
</div>
);
}
This example demonstrates a comprehensive use of memoization in a complex component:
- Data processing is memoized to avoid expensive re-calculations
- UI elements (header row) are memoized to avoid re-rendering
- Event handlers are memoized to maintain stable references
- Derived calculations (pagination) are memoized for efficiency
Without memoization, this component would perform poorly with large datasets or frequent user interactions.
Performance Testing and Measurement
To ensure your memoization efforts are effective, it's important to measure performance:
React DevTools Profiler
The React DevTools Profiler helps visualize component rendering:
- Record a session while interacting with your application
- Analyze which components are rendering and why
- Look for components that render unnecessarily
- Apply memoization strategically
- Record again to verify improvements
Console Logging
Basic console logging can help track renders and memoization:
// Log when the component renders
function ExpensiveComponent({ data }) {
console.log('ExpensiveComponent rendered');
// Log when calculations happen
const processedData = useMemo(() => {
console.log('Expensive calculation running');
return heavyProcessing(data);
}, [data]);
// ...
}
Performance Hooks
Custom hooks can help track and compare performance:
// Custom hook to measure execution time
function usePerformanceMeasure(name) {
return useCallback((callback) => {
const start = performance.now();
const result = callback();
const end = performance.now();
console.log(`${name} took ${end - start} ms`);
return result;
}, [name]);
}
// Usage
function DataProcessor({ data }) {
const measure = usePerformanceMeasure('Data processing');
// Measure without memoization
const resultWithoutMemo = measure(() => {
return processData(data);
});
// Measure with memoization
const resultWithMemo = useMemo(() => {
return measure(() => processData(data));
}, [data, measure]);
// Compare results
console.log('Results match:',
JSON.stringify(resultWithoutMemo) === JSON.stringify(resultWithMemo)
);
// ...
}
When Not to Memoize
Memoization isn't always beneficial. Here are signs you might be over-optimizing:
- Performance metrics show no significant improvement
- Code complexity has increased substantially
- Bugs related to stale closures are occurring
- The application feels responsive without memoization
Remember: Premature optimization is the root of all evil. Start with clean, working code, then optimize based on measurements.
Practice Activities
Activity 1: Fixing Memoization Issues
The following code has issues with unnecessary re-renders. Use useMemo and useCallback to fix them:
function ProductFilter({ products, onFilterChange }) {
const [filters, setFilters] = useState({
category: '',
minPrice: '',
maxPrice: ''
});
// Issue 1: This function is recreated on every render
const handleCategoryChange = (e) => {
const newFilters = {
...filters,
category: e.target.value
};
setFilters(newFilters);
onFilterChange(newFilters);
};
// Issue 2: This function is recreated on every render
const handlePriceChange = (field) => (e) => {
const newFilters = {
...filters,
[field]: e.target.value
};
setFilters(newFilters);
onFilterChange(newFilters);
};
// Issue 3: This value is recalculated on every render
const categories = Array.from(new Set(products.map(p => p.category)));
// Issue 4: This object is recreated on every render
const styles = {
container: { padding: '1rem', border: '1px solid #ccc' },
input: { margin: '0.5rem', padding: '0.25rem' }
};
return (
<div style={styles.container}>
<div>
<label>Category: </label>
<select
value={filters.category}
onChange={handleCategoryChange}
style={styles.input}
>
<option value="">All Categories</option>
{categories.map(category => (
<option key={category} value={category}>
{category}
</option>
))}
</select>
</div>
<div>
<label>Min Price: </label>
<input
type="number"
value={filters.minPrice}
onChange={handlePriceChange('minPrice')}
style={styles.input}
/>
</div>
<div>
<label>Max Price: </label>
<input
type="number"
value={filters.maxPrice}
onChange={handlePriceChange('maxPrice')}
style={styles.input}
/>
</div>
</div>
);
}
// Parent component has unnecessary re-renders
function ProductPage() {
const [products, setProducts] = useState([/* product data */]);
const [filteredProducts, setFilteredProducts] = useState(products);
// Issue 5: This function is recreated on every render
const handleFilterChange = (filters) => {
const result = products.filter(product => {
if (filters.category && product.category !== filters.category) {
return false;
}
if (filters.minPrice && product.price < Number(filters.minPrice)) {
return false;
}
if (filters.maxPrice && product.price > Number(filters.maxPrice)) {
return false;
}
return true;
});
setFilteredProducts(result);
};
return (
<div>
<h1>Products</h1>
<ProductFilter
products={products}
onFilterChange={handleFilterChange}
/>
<ProductList products={filteredProducts} />
</div>
);
}
// Fix the issues using useMemo and useCallback
Activity 2: Implementing Custom Hooks with Memoization
Create a custom hook called useSearch that implements search functionality with memoization:
// Implement the useSearch hook
function useSearch(items, searchOptions = {}) {
// The hook should:
// 1. Accept an array of items to search
// 2. Maintain search state (query, filters, etc.)
// 3. Return filtered items based on search criteria
// 4. Memoize search results and search functions
// 5. Be efficient with dependencies
// Example usage:
// const { results, searchQuery, setSearchQuery, filteredCount } = useSearch(products, {
// searchFields: ['name', 'description'],
// defaultQuery: ''
// });
}
// Test your hook in a component
function SearchableList({ items }) {
const {
results,
searchQuery,
setSearchQuery,
filteredCount,
totalCount
} = useSearch(items, {
searchFields: ['title', 'description']
});
return (
<div>
<div className="search-bar">
<input
type="text"
value={searchQuery}
onChange={e => setSearchQuery(e.target.value)}
placeholder="Search..."
/>
<div className="search-stats">
Showing {filteredCount} of {totalCount} items
</div>
</div>
<ul className="search-results">
{results.map(item => (
<li key={item.id}>
<h3>{item.title}</h3>
<p>{item.description}</p>
</li>
))}
</ul>
</div>
);
}
Activity 3: Analyzing and Optimizing a Complex Component
Analyze the following component for performance issues and optimize it with useMemo and useCallback:
function Dashboard() {
const [users, setUsers] = useState([]);
const [activities, setActivities] = useState([]);
const [stats, setStats] = useState({});
const [filters, setFilters] = useState({ timeRange: '7days', type: 'all' });
const [selectedUser, setSelectedUser] = useState(null);
// Fetch data
useEffect(() => {
Promise.all([
fetchUsers(),
fetchActivities(filters),
fetchStats(filters)
]).then(([usersData, activitiesData, statsData]) => {
setUsers(usersData);
setActivities(activitiesData);
setStats(statsData);
});
}, [filters]);
// Process activities
const processedActivities = activities.map(activity => {
const user = users.find(u => u.id === activity.userId);
return {
...activity,
userName: user ? user.name : 'Unknown User',
formattedDate: new Date(activity.timestamp).toLocaleDateString()
};
});
// Calculate summary statistics
const summary = {
totalActivities: activities.length,
averagePerDay: activities.length / 7,
mostActiveUser: users.length > 0 ?
users.reduce((most, user) => {
const userActivities = activities.filter(a => a.userId === user.id);
return userActivities.length > most.count ?
{ user, count: userActivities.length } : most;
}, { user: users[0], count: 0 }).user : null,
byType: activities.reduce((acc, activity) => {
acc[activity.type] = (acc[activity.type] || 0) + 1;
return acc;
}, {})
};
// Event handlers
const handleFilterChange = (e) => {
const { name, value } = e.target;
setFilters(prev => ({ ...prev, [name]: value }));
};
const handleUserSelect = (userId) => {
setSelectedUser(userId);
};
const handleExport = () => {
// Generate CSV export
const csvContent = processedActivities
.filter(a => !selectedUser || a.userId === selectedUser)
.map(a => `${a.formattedDate},${a.userName},${a.type},${a.details}`)
.join('\n');
// Download logic
const blob = new Blob([csvContent], { type: 'text/csv' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = 'activities.csv';
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
};
// Analyze this component and optimize it using useMemo and useCallback
// Focus on:
// 1. Identifying expensive calculations
// 2. Stabilizing function references
// 3. Preventing unnecessary re-renders
// 4. Managing dependencies correctly
return (
<div className="dashboard">
{/* Dashboard UI */}
</div>
);
}
Summary
useMemomemoizes the result of a calculation, recomputing only when dependencies changeuseCallbackmemoizes a function instance, creating a new function only when dependencies change- Use
useMemofor expensive calculations and to stabilize object references - Use
useCallbackfor event handlers and functions passed to child components - Properly manage dependencies to ensure correct memoization
- Use the functional update pattern with state setters to reduce dependencies
- Create custom hooks to encapsulate and reuse memoization logic
- Use performance measurement tools to validate optimization efforts
- Avoid premature optimization and over-memoization