32blogby Studio Mitsu

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.

by omitsu11 min read
ReactperformancememouseMemouseCallbackre-render
On this page

To stop unnecessary React re-renders, use React.memo to skip re-renders when props haven't changed, useMemo to stabilize object references and cache expensive calculations, and useCallback to stabilize function references. Always measure with React DevTools Profiler before optimizing.

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.

Scrolling a page with several hundred list items felt sluggish — a common scenario. Opening React DevTools Profiler and seeing every component flash on every interaction, including ones unrelated to the change, is the telltale sign that optimization is needed.

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.

What About the React Compiler?

The React Compiler (currently in beta) analyzes your code at build time and automatically inserts memoization where needed — effectively doing what memo, useMemo, and useCallback do manually.

Does that mean you can forget about re-render optimization? Not quite:

  • The Compiler is opt-in and still in beta. Most production apps don't use it yet
  • Understanding why re-renders happen helps you write better component architecture (splitting contexts, colocating state) — things the Compiler can't fix for you
  • Complex cases like custom comparison functions or architecture-level decisions still need human judgment

Think of the Compiler as a safety net, not a replacement for understanding. The patterns in this article remain the foundation, and the Compiler builds on them.

FAQ

Should I wrap every component with React.memo?

No. memo adds a props comparison cost on every render. For simple, lightweight components, this overhead can exceed the savings. Use memo where you've confirmed unnecessary re-renders in DevTools — typically heavy components or list items that re-render due to parent state changes.

What's the difference between useMemo and useCallback?

useCallback(fn, deps) is shorthand for useMemo(() => fn, deps). useMemo caches a value (including objects); useCallback caches a function reference. Use useCallback when passing functions to memoized children; use useMemo for expensive calculations or stabilizing object references.

Does React Compiler replace memo, useMemo, and useCallback?

The React Compiler automates memoization at build time, so you won't need to write these hooks manually in most cases. However, it's still in beta and opt-in. Understanding the fundamentals helps you write code that the Compiler can optimize effectively.

Why does my memoized component still re-render?

The most common cause: you're passing a new object or function reference as a prop. memo uses Object.is for comparison, so { name: "Alice" } !== { name: "Alice" } because they're different references. Stabilize props with useMemo (objects) and useCallback (functions).

Does memo work with children props?

Not well. JSX children like <Wrapper><Child /></Wrapper> create new React element references on every render, defeating memo. Consider using the children-as-props pattern or restructuring your component tree.

Can I use memo with React Server Components?

Server Components run on the server and don't re-render on the client, so memo is unnecessary. Use memo only on Client Components ('use client') where you've identified re-render issues.

How do I measure re-render performance?

Use the React DevTools Profiler: enable "Highlight updates when components render" to see re-renders visually, then use the flamegraph to identify which components take the most time. Chrome DevTools Performance tab can also show long tasks caused by excessive re-renders.

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

Related articles: