On this page

useRef, useMemo, and useCallback: Performance and DOM Access

12 min read TextCh. 2 — State and Effects

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:

  1. You measure an actual slowdown in React DevTools Profiler.
  2. A computation is genuinely expensive (e.g., sorting thousands of items).
  3. 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.

With React Compiler, useMemo and useCallback are often unnecessary
React 19's compiler automatically memoizes values and callbacks when it determines that optimization is beneficial. In new projects with the compiler enabled, start without `useMemo`/`useCallback` and only add them if you measure an actual performance problem.
useRef vs useState
Use `useRef` when you need to store a value that persists across renders but does NOT need to trigger a re-render when it changes. Common use cases: DOM references, timer IDs, previous prop values, and render counts. Changing `ref.current` is synchronous and does not re-render the component.
React.memo only does a shallow comparison
By default, `React.memo` re-renders if any prop reference changed, even if the value is deeply equal. Inline objects and functions are always new references — this is why `useCallback` is important when passing callbacks to memoized children.
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;