On this page
useRef, useMemo, and useCallback: Performance and DOM Access
useRef: Mutable Values Without Re-renders
useRef creates a mutable container that persists for the full lifetime of the component. Unlike state, changing ref.current does not trigger a re-render. The ref object always has the same identity across renders.
import { useRef } from 'react';
function StopWatch() {
const intervalRef = useRef<ReturnType<typeof setInterval> | null>(null);
const [elapsed, setElapsed] = useState(0);
function start() {
if (intervalRef.current !== null) return; // already running
intervalRef.current = setInterval(() => setElapsed((e) => e + 1), 1000);
}
function stop() {
if (intervalRef.current !== null) {
clearInterval(intervalRef.current);
intervalRef.current = null;
}
}
return (
<div>
<p>{elapsed}s</p>
<button type="button" onClick={start}>Start</button>
<button type="button" onClick={stop}>Stop</button>
</div>
);
}The interval ID is stored in a ref because we need it to persist across renders, but changing it should not cause the component to re-render.
Accessing DOM Elements with useRef
The most common use of useRef is obtaining a direct reference to a DOM element:
import { useRef, useEffect } from 'react';
function VideoPlayer({ src, isPlaying }: { src: string; isPlaying: boolean }) {
const videoRef = useRef<HTMLVideoElement>(null);
useEffect(() => {
const video = videoRef.current;
if (!video) return;
if (isPlaying) {
video.play();
} else {
video.pause();
}
}, [isPlaying]);
return (
<video
ref={videoRef}
src={src}
width={640}
height={360}
controls={false}
/>
);
}This works because video.play() and video.pause() are imperative — they are DOM operations that React cannot express declaratively. useRef is the escape hatch for these situations.
Tracking Previous Values
useRef is useful for tracking the previous value of a prop or state:
import { useRef, useEffect } from 'react';
function usePrevious<T>(value: T): T | undefined {
const ref = useRef<T | undefined>(undefined);
useEffect(() => {
ref.current = value;
});
return ref.current; // always the value from the previous render
}
function PriceDisplay({ price }: { price: number }) {
const prevPrice = usePrevious(price);
const direction = prevPrice === undefined
? 'neutral'
: price > prevPrice ? 'up' : price < prevPrice ? 'down' : 'neutral';
return (
<span className={`price price--${direction}`}>
${price.toFixed(2)}
</span>
);
}useMemo: Caching Expensive Computations
useMemo memoizes the result of a computation, recomputing it only when its dependencies change:
import { useMemo } from 'react';
interface Student {
id: number;
name: string;
score: number;
grade: string;
}
function GradeReport({ students }: { students: Student[] }) {
// Only recomputes when students changes
const stats = useMemo(() => {
if (students.length === 0) return null;
const total = students.reduce((sum, s) => sum + s.score, 0);
const average = total / students.length;
const highest = Math.max(...students.map((s) => s.score));
const lowest = Math.min(...students.map((s) => s.score));
const passing = students.filter((s) => s.score >= 60).length;
return { average, highest, lowest, passing, total: students.length };
}, [students]);
if (!stats) return <p>No students enrolled.</p>;
return (
<dl>
<dt>Average</dt><dd>{stats.average.toFixed(1)}</dd>
<dt>Highest</dt><dd>{stats.highest}</dd>
<dt>Lowest</dt><dd>{stats.lowest}</dd>
<dt>Passing</dt><dd>{stats.passing}/{stats.total}</dd>
</dl>
);
}Use useMemo when a computation is expensive (sorting/filtering large arrays, complex calculations) and the inputs are not changing every render.
useCallback: Stable Function References
useCallback memoizes a function reference, ensuring the same function object is returned across renders as long as its dependencies do not change:
import { useCallback, useState } from 'react';
function useSelection<T extends { id: number }>(items: T[]) {
const [selectedIds, setSelectedIds] = useState<Set<number>>(new Set());
const toggle = useCallback((id: number) => {
setSelectedIds((prev) => {
const next = new Set(prev);
if (next.has(id)) {
next.delete(id);
} else {
next.add(id);
}
return next;
});
}, []); // no dependencies — stable forever
const selectAll = useCallback(() => {
setSelectedIds(new Set(items.map((i) => i.id)));
}, [items]);
const clearAll = useCallback(() => {
setSelectedIds(new Set());
}, []);
return { selectedIds, toggle, selectAll, clearAll };
}useCallback(fn, deps) is equivalent to useMemo(() => fn, deps). Both cache across renders.
React.memo: Preventing Component Re-renders
React.memo is a higher-order component that skips re-rendering a component if its props have not changed (shallow comparison):
import { memo } from 'react';
interface AvatarProps {
src: string;
alt: string;
size?: number;
}
// Will only re-render if src, alt, or size changes
const Avatar = memo(function Avatar({ src, alt, size = 40 }: AvatarProps) {
return (
<img
src={src}
alt={alt}
width={size}
height={size}
className="avatar"
/>
);
});
export { Avatar };The combination of React.memo on child components and useCallback for handler functions passed as props is the classic performance optimization pattern — though in React 19 with the Compiler, this is often handled automatically.
When to Optimize
Do not add useMemo, useCallback, or React.memo preemptively. The overhead of memoization can sometimes outweigh its benefit. Add these optimizations when:
- You measure an actual slowdown in React DevTools Profiler.
- A computation is genuinely expensive (e.g., sorting thousands of items).
- A memoized child is re-rendering more often than necessary due to reference instability.
The React Compiler in React 19.2 handles the majority of these cases automatically. Profile first, optimize second.
In the next lesson, you will learn how to extract stateful logic into custom hooks — one of the most powerful patterns in React.
import { useState, useMemo, useCallback, memo } from 'react';
interface Item {
id: number;
name: string;
category: string;
price: number;
}
const ITEMS: Item[] = [
{ id: 1, name: 'React Course', category: 'education', price: 49 },
{ id: 2, name: 'TypeScript Handbook', category: 'education', price: 29 },
{ id: 3, name: 'Mechanical Keyboard', category: 'hardware', price: 149 },
{ id: 4, name: 'Monitor Stand', category: 'hardware', price: 79 },
{ id: 5, name: 'CSS Secrets', category: 'education', price: 35 },
];
interface ItemRowProps {
item: Item;
onSelect: (id: number) => void;
}
// memo prevents re-render if props haven't changed
const ItemRow = memo(function ItemRow({ item, onSelect }: ItemRowProps) {
console.log('Rendering ItemRow', item.id);
return (
<tr>
<td>{item.name}</td>
<td>{item.category}</td>
<td>${item.price}</td>
<td>
<button type="button" onClick={() => onSelect(item.id)}>
Select
</button>
</td>
</tr>
);
});
function FilterList() {
const [query, setQuery] = useState('');
const [category, setCategory] = useState('all');
const [selectedId, setSelectedId] = useState<number | null>(null);
// Recomputes only when ITEMS, query, or category change
const filtered = useMemo(() => {
return ITEMS
.filter((item) =>
item.name.toLowerCase().includes(query.toLowerCase())
)
.filter((item) =>
category === 'all' ? true : item.category === category
);
}, [query, category]);
// Stable reference — does not change on re-renders
const handleSelect = useCallback((id: number) => {
setSelectedId(id);
}, []);
const totalValue = useMemo(
() => filtered.reduce((sum, item) => sum + item.price, 0),
[filtered]
);
return (
<div>
<input
type="search"
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder="Search items..."
/>
<select value={category} onChange={(e) => setCategory(e.target.value)}>
<option value="all">All categories</option>
<option value="education">Education</option>
<option value="hardware">Hardware</option>
</select>
<p>Total value: ${totalValue}</p>
{selectedId && <p>Selected: #{selectedId}</p>}
<table>
<tbody>
{filtered.map((item) => (
<ItemRow key={item.id} item={item} onSelect={handleSelect} />
))}
</tbody>
</table>
</div>
);
}
export default FilterList;
Sign in to track your progress