Javascript
Front End
React.js
hooks
performance
In React applications, function components re-render when their props or state change. During each render, the functions defined inside your component are recreated. This is usually fine, but can cause performance issues in certain scenarios. The useCallback
hook helps solve this problem by memoizing callback functions.
useCallback
is a React hook that returns a memoized version of the callback function you provide. This memoized function will only change if one of its dependencies has changed. This can help prevent unnecessary re-renders of components that rely on reference equality of functions.
1const memoizedCallback = useCallback( 2 () => { 3 doSomething(a, b); 4 }, 5 [a, b], 6);
In this syntax:
useEffect
and useMemo
)In React, when a component re-renders, everything inside that component gets recreated, including functions. This means:
1function ParentComponent() { 2 // This function is recreated on every render 3 const handleClick = () => { 4 console.log('Clicked!'); 5 }; 6 7 return <ChildComponent onClick={handleClick} />; 8}
Even though handleClick
does the same thing on every render, it's a different function object each time. If ChildComponent
is using React.memo
or is otherwise comparing props for reference equality, it will re-render unnecessarily.
Let's compare the two approaches:
1function ParentComponent() { 2 const [count, setCount] = useState(0); 3 4 // A new function reference is created on every render 5 const handleIncrement = () => { 6 setCount(count + 1); 7 }; 8 9 return ( 10 <div> 11 <p>Count: {count}</p> 12 <button onClick={() => setCount(count + 1)}> 13 Increment 14 </button> 15 <ExpensiveChild onIncrement={handleIncrement} /> 16 </div> 17 ); 18} 19 20// Using React.memo to prevent unnecessary renders 21const ExpensiveChild = React.memo(({ onIncrement }) => { 22 console.log("ExpensiveChild rendered"); 23 return <button onClick={onIncrement}>Child Increment</button>; 24});
In this example, ExpensiveChild
will re-render on every render of ParentComponent
because handleIncrement
is a new function each time.
1function ParentComponent() { 2 const [count, setCount] = useState(0); 3 4 // The function reference remains the same between renders 5 // as long as count doesn't change 6 const handleIncrement = useCallback(() => { 7 setCount(count + 1); 8 }, [count]); 9 10 return ( 11 <div> 12 <p>Count: {count}</p> 13 <button onClick={() => setCount(count + 1)}> 14 Increment 15 </button> 16 <ExpensiveChild onIncrement={handleIncrement} /> 17 </div> 18 ); 19} 20 21// Using React.memo to prevent unnecessary renders 22const ExpensiveChild = React.memo(({ onIncrement }) => { 23 console.log("ExpensiveChild rendered"); 24 return <button onClick={onIncrement}>Child Increment</button>; 25});
Now, ExpensiveChild
will only re-render when count
changes, because the handleIncrement
function's reference remains stable between renders.
You should consider using useCallback
when:
useEffect
or useMemo
1function SearchComponent() { 2 const [query, setQuery] = useState(''); 3 const [results, setResults] = useState([]); 4 5 // Memoize the search function 6 const handleSearch = useCallback(async (searchQuery) => { 7 const data = await fetchSearchResults(searchQuery); 8 setResults(data); 9 }, []); // Empty deps means this function never changes 10 11 return ( 12 <div> 13 <SearchInput 14 value={query} 15 onChange={setQuery} 16 onSearch={() => handleSearch(query)} 17 /> 18 <MemoizedResultsList results={results} /> 19 </div> 20 ); 21} 22 23// ResultsList is wrapped in React.memo to prevent re-renders when props don't change 24const MemoizedResultsList = React.memo(ResultsList);
1function DataFetcher({ itemId }) { 2 const [data, setData] = useState(null); 3 4 // Memoize the fetch function so it doesn't change on every render 5 const fetchData = useCallback(async () => { 6 const result = await fetch(`/api/items/${itemId}`); 7 const json = await result.json(); 8 setData(json); 9 }, [itemId]); // Only changes when itemId changes 10 11 // Now we can use fetchData as a dependency in useEffect 12 useEffect(() => { 13 fetchData(); 14 15 // Set up polling 16 const intervalId = setInterval(() => { 17 fetchData(); 18 }, 10000); 19 20 return () => clearInterval(intervalId); 21 }, [fetchData]); // Dependencies include our stable callback 22 23 return data ? <ItemDisplay data={data} /> : <Loading />; 24}
1function FormWithValidation({ onSubmit }) { 2 const [values, setValues] = useState({ name: '', email: '' }); 3 const [errors, setErrors] = useState({}); 4 5 // Memoize the validation function 6 const validate = useCallback(() => { 7 const newErrors = {}; 8 9 if (!values.name) newErrors.name = 'Name is required'; 10 if (!values.email) newErrors.email = 'Email is required'; 11 else if (!/\S+@\S+\.\S+/.test(values.email)) { 12 newErrors.email = 'Email is invalid'; 13 } 14 15 setErrors(newErrors); 16 return Object.keys(newErrors).length === 0; 17 }, [values]); 18 19 // Memoize the submit handler 20 const handleSubmit = useCallback((e) => { 21 e.preventDefault(); 22 if (validate()) { 23 onSubmit(values); 24 } 25 }, [validate, values, onSubmit]); 26 27 return ( 28 <form onSubmit={handleSubmit}> 29 {/* Form fields */} 30 </form> 31 ); 32}
useCallback
returns a memoized functionuseMemo
returns a memoized value (the result of calling a function)Use useCallback
when you want to memoize a function itself, and useMemo
when you want to memoize the result of a function.
1// Memoize a function 2const handleClick = useCallback(() => { 3 console.log('Button clicked!'); 4}, []); 5 6// Memoize a calculated value 7const expensiveValue = useMemo(() => { 8 return computeExpensiveValue(a, b); 9}, [a, b]);
When your callback function depends on the previous state, it's better to use the functional update form to avoid adding the state value as a dependency:
1const [count, setCount] = useState(0); 2 3// This needs count as a dependency 4const increment = useCallback(() => { 5 setCount(count + 1); 6}, [count]); // count dependency means function changes when count changes
1const [count, setCount] = useState(0); 2 3// This doesn't need count as a dependency 4const increment = useCallback(() => { 5 setCount(prevCount => prevCount + 1); 6}, []); // No dependencies means function is stable
1function List({ items }) { 2 // Memoize the handler with setItem as parameter 3 const handleItemClick = useCallback((id) => { 4 console.log(`Item ${id} clicked`); 5 }, []); 6 7 return ( 8 <ul> 9 {items.map(item => ( 10 <ListItem 11 key={item.id} 12 item={item} 13 // Pass the memoized function with the item id 14 onClick={() => handleItemClick(item.id)} 15 /> 16 ))} 17 </ul> 18 ); 19} 20 21// This component is optimized to not re-render when parent re-renders 22const ListItem = React.memo(({ item, onClick }) => { 23 return <li onClick={onClick}>{item.name}</li>; 24});
1function SearchInput() { 2 const [query, setQuery] = useState(''); 3 4 // Create a stable reference to the search function 5 const debouncedSearch = useCallback( 6 debounce((searchTerm) => { 7 // Perform search 8 console.log('Searching for:', searchTerm); 9 }, 500), 10 [] // This function never changes 11 ); 12 13 const handleChange = (e) => { 14 const value = e.target.value; 15 setQuery(value); 16 debouncedSearch(value); 17 }; 18 19 return ( 20 <input 21 type="text" 22 value={query} 23 onChange={handleChange} 24 placeholder="Search..." 25 /> 26 ); 27} 28 29// Simple debounce implementation 30function debounce(fn, delay) { 31 let timeoutId; 32 return function(...args) { 33 clearTimeout(timeoutId); 34 timeoutId = setTimeout(() => { 35 fn(...args); 36 }, delay); 37 }; 38}
1const ExpensiveComponent = React.memo(function ExpensiveComponent({ onSave, data }) { 2 // Complex rendering logic 3 return ( 4 <div> 5 <h2>{data.title}</h2> 6 <p>{data.description}</p> 7 <button onClick={() => onSave(data)}>Save</button> 8 </div> 9 ); 10}); 11 12function Container() { 13 const [items, setItems] = useState([/* initial data */]); 14 15 // Stable save handler 16 const handleSave = useCallback((item) => { 17 // Save logic 18 console.log('Saving:', item); 19 // Update items 20 setItems(prevItems => { 21 return prevItems.map(i => 22 i.id === item.id ? {...i, saved: true} : i 23 ); 24 }); 25 }, []); // No dependencies, function remains stable across renders 26 27 return ( 28 <div> 29 {items.map(item => ( 30 <ExpensiveComponent 31 key={item.id} 32 data={item} 33 onSave={handleSave} 34 /> 35 ))} 36 </div> 37 ); 38}
Like with any optimization technique, useCallback
comes with tradeoffs:
useCallback
can make your code harder to read and maintainDon't use useCallback
for every function in your component by default. Only use it when you've identified a specific performance issue or when:
React.memo
useEffect
You can combine useCallback
with useRef
to maintain function identity across renders while still accessing the latest props or state:
1function SearchComponent({ apiKey }) { 2 const [query, setQuery] = useState(''); 3 const apiKeyRef = useRef(apiKey); 4 5 // Update ref when apiKey changes 6 useEffect(() => { 7 apiKeyRef.current = apiKey; 8 }, [apiKey]); 9 10 // This function never changes identity 11 const search = useCallback(() => { 12 // Access latest apiKey via ref 13 const currentApiKey = apiKeyRef.current; 14 // Perform search with query and apiKey 15 console.log(`Searching for "${query}" with key ${currentApiKey}`); 16 }, [query]); // Only depends on query, not apiKey 17 18 return ( 19 <div> 20 <input 21 value={query} 22 onChange={e => setQuery(e.target.value)} 23 /> 24 <button onClick={search}>Search</button> 25 </div> 26 ); 27}
1import React, { useState, useCallback, useMemo } from 'react'; 2 3function DataGrid({ data, columns }) { 4 const [sortConfig, setSortConfig] = useState({ key: null, direction: 'asc' }); 5 const [filters, setFilters] = useState({}); 6 7 // Memoize the sort function 8 const handleSort = useCallback((columnKey) => { 9 setSortConfig(prevSort => { 10 if (prevSort.key === columnKey) { 11 // Toggle direction if same column 12 return { 13 key: columnKey, 14 direction: prevSort.direction === 'asc' ? 'desc' : 'asc' 15 }; 16 } 17 18 // Default to ascending for new column 19 return { key: columnKey, direction: 'asc' }; 20 }); 21 }, []); 22 23 // Memoize the filter function 24 const handleFilterChange = useCallback((columnKey, value) => { 25 setFilters(prevFilters => ({ 26 ...prevFilters, 27 [columnKey]: value 28 })); 29 }, []); 30 31 // Clear all filters 32 const clearFilters = useCallback(() => { 33 setFilters({}); 34 }, []); 35 36 // Apply sorting and filtering with useMemo 37 const processedData = useMemo(() => { 38 // First apply filters 39 let result = data.filter(item => { 40 // Check each filter 41 return Object.entries(filters).every(([key, value]) => { 42 if (!value) return true; // Skip empty filters 43 44 const itemValue = String(item[key] || '').toLowerCase(); 45 return itemValue.includes(String(value).toLowerCase()); 46 }); 47 }); 48 49 // Then sort 50 if (sortConfig.key) { 51 result = [...result].sort((a, b) => { 52 const aValue = a[sortConfig.key]; 53 const bValue = b[sortConfig.key]; 54 55 if (aValue < bValue) { 56 return sortConfig.direction === 'asc' ? -1 : 1; 57 } 58 if (aValue > bValue) { 59 return sortConfig.direction === 'asc' ? 1 : -1; 60 } 61 return 0; 62 }); 63 } 64 65 return result; 66 }, [data, filters, sortConfig]); 67 68 return ( 69 <div className="data-grid"> 70 <div className="filters"> 71 {columns.map(column => ( 72 <div key={column.key} className="filter"> 73 <label>{column.label} Filter:</label> 74 <input 75 value={filters[column.key] || ''} 76 onChange={e => handleFilterChange(column.key, e.target.value)} 77 placeholder={`Filter by ${column.label}`} 78 /> 79 </div> 80 ))} 81 <button onClick={clearFilters}>Clear Filters</button> 82 </div> 83 84 <table> 85 <thead> 86 <tr> 87 {columns.map(column => ( 88 <th 89 key={column.key} 90 onClick={() => handleSort(column.key)} 91 className={sortConfig.key === column.key ? sortConfig.direction : ''} 92 > 93 {column.label} 94 {sortConfig.key === column.key && ( 95 <span>{sortConfig.direction === 'asc' ? ' ▲' : ' ▼'}</span> 96 )} 97 </th> 98 ))} 99 </tr> 100 </thead> 101 <tbody> 102 {processedData.map((row, index) => ( 103 <tr key={row.id || index}> 104 {columns.map(column => ( 105 <td key={column.key}>{row[column.key]}</td> 106 ))} 107 </tr> 108 ))} 109 </tbody> 110 </table> 111 112 <div className="summary"> 113 Showing {processedData.length} of {data.length} rows 114 </div> 115 </div> 116 ); 117} 118 119// Usage 120function App() { 121 const columns = [ 122 { key: 'name', label: 'Name' }, 123 { key: 'age', label: 'Age' }, 124 { key: 'occupation', label: 'Occupation' } 125 ]; 126 127 const data = [ 128 { id: 1, name: 'John Doe', age: 28, occupation: 'Developer' }, 129 { id: 2, name: 'Jane Smith', age: 32, occupation: 'Designer' }, 130 { id: 3, name: 'Bob Johnson', age: 45, occupation: 'Manager' }, 131 // More data... 132 ]; 133 134 return <DataGrid data={data} columns={columns} />; 135}
In this complex example:
useCallback
to memoize event handlers for sorting and filteringuseMemo
to process the data based on current sort and filter settingsThe useCallback
hook is a valuable tool for optimizing React applications by memoizing functions to prevent unnecessary re-renders. It's especially useful when:
Key takeaways:
useCallback
to memoize functions that should maintain the same reference between rendersReact.memo
to prevent unnecessary child component re-rendersuseCallback
when there's a clear performance benefitRemember that premature optimization can lead to more complex code without meaningful performance gains. Use useCallback
strategically when you've identified specific performance issues or in the scenarios outlined in this lesson.