32blogby StudioMitsu
react9 min read

React Re-render Optimization: Practical Guide

Stop unnecessary re-renders with memo, useMemo, and useCallback. Learn when to use each, how to measure, and common pitfalls.

reactperformancememouseMemouseCallbackre-render
On this page

When React feels slow, unnecessary re-renders are usually the culprit. Components re-execute more often than needed, and the accumulated cost shows up as jank.

I once had a page with a list of several hundred items. Scrolling felt sluggish. I opened React DevTools and found every component re-rendering on every interaction — including ones that had nothing to do with the change.

This article explains how re-renders work, then shows you exactly how to use memo, useMemo, and useCallback to stop the unnecessary ones. Not "sprinkle memo everywhere" — but understanding why each tool is needed in each scenario.

Why Do Re-renders Happen?

React re-renders a component in three situations:

  1. Its own state changed (setState was called)
  2. Its parent re-rendered
  3. A context it consumes changed

The second point is the most surprising: when a parent re-renders, all its children re-render by default — regardless of whether their props changed.

tsx
function Parent() {
  const [count, setCount] = useState(0);

  return (
    <div>
      <button onClick={() => setCount(c => c + 1)}>+1</button>
      {/* ChildA and ChildB re-render every time count changes */}
      <ChildA />
      <ChildB />
    </div>
  );
}

ChildA and ChildB don't receive count as a prop, yet they re-render whenever Parent state changes. At scale, this accumulates into real performance problems.

Re-renders Aren't Inherently Bad

Important caveat: re-renders themselves aren't evil. React is designed to make them fast, and actual DOM updates are minimized to the diff. Dozens of simple components re-rendering is imperceptible to users.

Optimization becomes necessary when:

  • Components contain expensive calculations
  • Lists have hundreds of items
  • You need smooth 60fps animations

Measure first, then optimize. This is non-negotiable.

Visualize Re-renders with React DevTools

Before optimizing, identify what's actually re-rendering unnecessarily.

Use the Profiler tab in React DevTools (browser extension):

  1. Install React DevTools
  2. Open the Profiler tab
  3. Click the gear icon → enable "Highlight updates when components render"
  4. Interact with your app — re-rendered components will flash

Color guide:

  • Blue — rare renders (fine)
  • Green — moderate
  • Yellow — frequent
  • Red — very frequent (investigate)

React.memo: Skip Re-renders When Props Haven't Changed

React.memo wraps a component and skips re-rendering when props are unchanged (compared with Object.is).

Without Optimization

tsx
function Parent() {
  const [count, setCount] = useState(0);
  const [name, setName] = useState("Alice");

  return (
    <div>
      <button onClick={() => setCount(c => c + 1)}>count: {count}</button>
      <input value={name} onChange={e => setName(e.target.value)} />
      {/* NameDisplay re-renders every time count changes */}
      <NameDisplay name={name} />
    </div>
  );
}

function NameDisplay({ name }: { name: string }) {
  console.log("NameDisplay rendered"); // Called every time
  return <p>Hello, {name}</p>;
}

With React.memo

tsx
// Only re-renders when name actually changes
const NameDisplay = memo(function NameDisplay({ name }: { name: string }) {
  console.log("NameDisplay rendered"); // Only called when name changes
  return <p>Hello, {name}</p>;
});

When memo Doesn't Work

tsx
function Parent() {
  const [count, setCount] = useState(0);

  // ❌ New object reference on every render
  const user = { name: "Alice", age: 20 };
  // ❌ New function reference on every render
  const handleClick = () => console.log("clicked");

  return (
    <div>
      <button onClick={() => setCount(c => c + 1)}>+1</button>
      {/* user and handleClick have new references every render — memo can't help */}
      <UserCard user={user} onClick={handleClick} />
    </div>
  );
}

const UserCard = memo(function UserCard({
  user,
  onClick,
}: {
  user: { name: string; age: number };
  onClick: () => void;
}) {
  console.log("UserCard rendered"); // Still called every time
  return (
    <div onClick={onClick}>
      {user.name} ({user.age})
    </div>
  );
});

Objects and functions create new references on every render. memo sees "props changed" and re-renders anyway. This is where useMemo and useCallback come in.

useMemo: Stabilize Object References and Cache Expensive Calculations

useMemo memoizes a computed value, returning the same reference unless dependencies change.

tsx
function Parent() {
  const [count, setCount] = useState(0);
  const [name, setName] = useState("Alice");
  const [age, setAge] = useState(20);

  // ✅ New object only when name or age changes
  const user = useMemo(() => ({ name, age }), [name, age]);

  return (
    <div>
      <button onClick={() => setCount(c => c + 1)}>count: {count}</button>
      {/* When only count changes, user reference stays stable — UserCard skips re-render */}
      <UserCard user={user} />
    </div>
  );
}

const UserCard = memo(function UserCard({
  user,
}: {
  user: { name: string; age: number };
}) {
  console.log("UserCard rendered");
  return <div>{user.name} ({user.age})</div>;
});

Caching Expensive Calculations

tsx
function SearchResults({ items, query }: { items: Item[]; query: string }) {
  // ❌ Filter runs on every render
  // const filtered = items.filter(item => item.name.includes(query));

  // ✅ Filter only runs when items or query changes
  const filtered = useMemo(
    () => items.filter(item =>
      item.name.toLowerCase().includes(query.toLowerCase())
    ),
    [items, query]
  );

  return (
    <ul>
      {filtered.map(item => (
        <li key={item.id}>{item.name}</li>
      ))}
    </ul>
  );
}

For 1,000+ items with complex filtering or sorting, useMemo pays off. For 10 simple items, the useMemo overhead likely exceeds the savings.

useCallback: Stabilize Function References

useCallback memoizes a function, returning the same reference unless dependencies change.

tsx
function Parent() {
  const [count, setCount] = useState(0);
  const [name, setName] = useState("Alice");

  // ✅ New function only when name changes
  const handleUserClick = useCallback(() => {
    console.log(`${name} was clicked`);
  }, [name]);

  return (
    <div>
      <button onClick={() => setCount(c => c + 1)}>count: {count}</button>
      {/* When only count changes, handleUserClick reference stays stable */}
      <UserCard onClick={handleUserClick} />
    </div>
  );
}

const UserCard = memo(function UserCard({ onClick }: { onClick: () => void }) {
  console.log("UserCard rendered");
  return <button onClick={onClick}>Click me</button>;
});

useMemo vs useCallback

useCallback(fn, deps) is equivalent to useMemo(() => fn, deps). Think of useCallback as shorthand for memoizing functions:

tsx
// These two are identical
const fn1 = useCallback(() => doSomething(a, b), [a, b]);
const fn2 = useMemo(() => () => doSomething(a, b), [a, b]);

Real-World Example: Optimizing a Heavy List

Let's walk through a complete optimization of a large todo list.

Before

tsx
function TodoList() {
  const [todos, setTodos] = useState(initialTodos); // 500 items
  const [filter, setFilter] = useState("all");

  // Filtering runs on every render
  const filtered = todos.filter(todo => {
    if (filter === "done") return todo.done;
    if (filter === "pending") return !todo.done;
    return true;
  });

  // New function references on every render
  const handleToggle = (id: string) => {
    setTodos(prev => prev.map(t => t.id === id ? { ...t, done: !t.done } : t));
  };

  const handleDelete = (id: string) => {
    setTodos(prev => prev.filter(t => t.id !== id));
  };

  return (
    <div>
      <FilterButtons filter={filter} onChange={setFilter} />
      <ul>
        {filtered.map(todo => (
          // Every TodoItem re-renders when filter changes
          <TodoItem
            key={todo.id}
            todo={todo}
            onToggle={handleToggle}
            onDelete={handleDelete}
          />
        ))}
      </ul>
    </div>
  );
}

After

tsx
function TodoList() {
  const [todos, setTodos] = useState(initialTodos); // 500 items
  const [filter, setFilter] = useState("all");

  // ✅ Filter only recalculates when todos or filter changes
  const filtered = useMemo(
    () => todos.filter(todo => {
      if (filter === "done") return todo.done;
      if (filter === "pending") return !todo.done;
      return true;
    }),
    [todos, filter]
  );

  // ✅ Stable references — functional updates mean no need for todos in deps
  const handleToggle = useCallback((id: string) => {
    setTodos(prev => prev.map(t => t.id === id ? { ...t, done: !t.done } : t));
  }, []);

  const handleDelete = useCallback((id: string) => {
    setTodos(prev => prev.filter(t => t.id !== id));
  }, []);

  return (
    <div>
      <FilterButtons filter={filter} onChange={setFilter} />
      <ul>
        {filtered.map(todo => (
          <TodoItem
            key={todo.id}
            todo={todo}
            onToggle={handleToggle} // Stable reference
            onDelete={handleDelete} // Stable reference
          />
        ))}
      </ul>
    </div>
  );
}

// ✅ memo wrap — skips re-render when todo, onToggle, onDelete haven't changed
const TodoItem = memo(function TodoItem({
  todo,
  onToggle,
  onDelete,
}: {
  todo: Todo;
  onToggle: (id: string) => void;
  onDelete: (id: string) => void;
}) {
  return (
    <li>
      <span style={{ textDecoration: todo.done ? "line-through" : "none" }}>
        {todo.text}
      </span>
      <button onClick={() => onToggle(todo.id)}>Toggle</button>
      <button onClick={() => onDelete(todo.id)}>Delete</button>
    </li>
  );
});

What changed:

  1. useMemo for filtering — 500-item filter no longer runs on unrelated renders
  2. useCallback for handlers — functional updates (prev =>) let us omit todos from deps
  3. memo on TodoItem — individual items skip re-renders when nothing changed for them

Prevent Context-Caused Re-renders

When a context value changes, every component consuming that context re-renders. Overloading a single context causes cascading re-renders.

tsx
// ❌ Everything in one context
const AppContext = createContext({
  user: null,
  theme: "dark",
  notifications: [],
});

// A notification update causes components only using user to re-render
tsx
// ✅ Split by update frequency
const UserContext = createContext(null);
const ThemeContext = createContext("dark");
const NotificationContext = createContext([]);

// Components using UserContext only re-render when user changes

Group context values by how often they change together.

Wrapping Up

Re-render optimization follows: measure → identify → fix.

ToolPurposeUse When
React.memoSkip re-renders when props unchangedHeavy components with confirmed unnecessary re-renders
useMemoCache values and stabilize object referencesExpensive calculations, objects passed to memoized components
useCallbackStabilize function referencesFunctions passed to memoized components, functions in useEffect deps

"Sprinkle memo everywhere" often makes things worse. Open React DevTools Profiler, confirm which re-renders are unnecessary, trace the cause, then apply the right fix.

Optimization priority:

  1. Measure and confirm the problem exists
  2. Consider structural solutions (component colocation) first
  3. Then reach for memouseCallbackuseMemo