AdSense Leaderboard

Google AdSense Placeholder

HEADER SLOT

useCallback Hook Guide: Memoizing Functions Correctly

Last updated:
useCallback: Optimizing Function References in React

Master useCallback for React performance. Learn when to memoize functions, avoid common pitfalls, combine with memo(), and understand the React Compiler impact.

# useCallback Hook Guide: Memoizing Functions Correctly

useCallback is one of React's most misunderstood Hooks. Developers often add it everywhere, hoping to boost performance, only to find their components are just as slow—or slower. The problem? useCallback isn't a magic performance pill. It's a surgical tool that solves a specific problem. Use it correctly, and you'll prevent unnecessary re-renders. Use it wrong, and you'll add overhead without benefit. This guide cuts through the confusion and shows you exactly when useCallback matters.

# Table of Contents

  1. What is useCallback and Why It Exists
  2. The Problem It Solves
  3. How useCallback Works
  4. useCallback vs useMemo
  5. When to Actually Use useCallback
  6. Common Mistakes
  7. Combining useCallback with memo()
  8. Real-World Patterns
  9. The React Compiler Change
  10. FAQ

# What is useCallback and Why It Exists

# The Basic Concept

useCallback memoizes a function, meaning it keeps the same function reference across re-renders (unless dependencies change). Instead of creating a new function every time the component renders, you get the old one back:

typescript
import { useCallback } from 'react';

export function MyComponent() {
  // Without useCallback: new function created on every render
  const handleClick = () => {
    console.log('Clicked');
  };

  // With useCallback: same function returned unless dependencies change
  const memoizedClick = useCallback(() => {
    console.log('Clicked');
  }, []);

  return <button onClick={memoizedClick}>Click me</button>;
}
javascript
import { useCallback } from 'react';

export function MyComponent() {
  // Without useCallback: new function created on every render
  const handleClick = () => {
    console.log('Clicked');
  };

  // With useCallback: same function returned unless dependencies change
  const memoizedClick = useCallback(() => {
    console.log('Clicked');
  }, []);

  return <button onClick={memoizedClick}>Click me</button>;
}

# The Syntax

useCallback takes two arguments:

  1. The function to memoize: Your callback function
  2. The dependency array: When dependencies change, a new function is created
typescript
const memoizedCallback = useCallback(
  () => {
    // Function body
  },
  [dependency1, dependency2] // Dependencies
);

# The Problem It Solves

# Functions Are Re-created Every Render

In JavaScript, functions are objects. Each time you define a function in a component, you create a new object, even if the code is identical:

typescript
export function Parent() {
  // NEW function object created on every render
  const handleButtonClick = () => {
    console.log('Button clicked');
  };

  return <Child onClick={handleButtonClick} />;
}

interface ChildProps {
  onClick: () => void;
}

export function Child({ onClick }: ChildProps) {
  console.log('Child rendered');
  return <button onClick={onClick}>Click</button>;
}
javascript
export function Parent() {
  const handleButtonClick = () => {
    console.log('Button clicked');
  };

  return <Child onClick={handleButtonClick} />;
}

export function Child({ onClick }) {
  console.log('Child rendered');
  return <button onClick={onClick}>Click</button>;
}

What happens:

  1. Parent renders → creates new handleButtonClick function
  2. Parent passes it to Child via props
  3. Even though Child's code is the same, the onClick prop is a different object
  4. If Child is wrapped with memo(), it sees a new prop value and re-renders anyway

This breaks the optimization benefit of memo().


# How useCallback Works

# With useCallback

Wrap your function with useCallback to keep the same reference:

typescript
import { useCallback } from 'react';

export function Parent() {
  // Same function reference returned every render (unless dependencies change)
  const handleButtonClick = useCallback(() => {
    console.log('Button clicked');
  }, []);

  return <Child onClick={handleButtonClick} />;
}

interface ChildProps {
  onClick: () => void;
}

export function Child({ onClick }: ChildProps) {
  console.log('Child rendered');
  return <button onClick={onClick}>Click</button>;
}
javascript
export function Parent() {
  const handleButtonClick = useCallback(() => {
    console.log('Button clicked');
  }, []);

  return <Child onClick={handleButtonClick} />;
}

export function Child({ onClick }) {
  console.log('Child rendered');
  return <button onClick={onClick}>Click</button>;
}

What happens:

  1. Parent renders → useCallback returns the memoized function
  2. Parent passes it to Child via props
  3. The onClick prop is the same object as before
  4. Child's memoization (if using memo()) sees the same prop value and doesn't re-render

# Functions with Dependencies

If your callback uses values that change, include them in the dependency array:

typescript
import { useCallback, useState } from 'react';

export function Counter() {
  const [count, setCount] = useState(0);

  // New callback created when count changes
  const increment = useCallback(() => {
    setCount(count + 1);
  }, [count]); // count is a dependency

  return (
    <div>
      <p>Count: {count}</p>
      <button onClick={increment}>Increment</button>
    </div>
  );
}
javascript
export function Counter() {
  const [count, setCount] = useState(0);

  const increment = useCallback(() => {
    setCount(count + 1);
  }, [count]);

  return (
    <div>
      <p>Count: {count}</p>
      <button onClick={increment}>Increment</button>
    </div>
  );
}

# useCallback vs useMemo

# The Key Difference

Hook Purpose Returns
useCallback Memoizes a function The function itself
useMemo Memoizes a value The value (could be anything)

# Side-by-Side Comparison

useCallback:

typescript
// Memoize the function itself
const memoizedFunc = useCallback(() => {
  return doSomething();
}, [dependencies]);

useMemo:

typescript
// Memoize the result of calling the function
const memoizedValue = useMemo(() => {
  return doSomething();
}, [dependencies]);

# In Practice

You can use useMemo to replace useCallback:

typescript
// These are equivalent:

// Option 1: useCallback (purpose-built for functions)
const handleClick = useCallback(() => {
  setCount(c => c + 1);
}, []);

// Option 2: useMemo (more general, but less clear intent)
const handleClick = useMemo(() => {
  return () => {
    setCount(c => c + 1);
  };
}, []);

Recommendation: Use useCallback when memoizing functions—it's clearer and more concise.


# When to Actually Use useCallback

# ✅ USE useCallback When

1. Passing callback to memoized child component

typescript
import { memo, useCallback } from 'react';

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

  // ✅ useCallback makes sense here
  const handleDelete = useCallback((id: string) => {
    // Call API to delete
    setCount(c => c - 1);
  }, []);

  // Child is memoized, so stable callback reference matters
  return <MemoizedList onDelete={handleDelete} />;
}

interface ListProps {
  onDelete: (id: string) => void;
}

const MemoizedList = memo(function List({ onDelete }: ListProps) {
  return <div>List items...</div>;
});

2. Using callback as dependency in useEffect

typescript
import { useCallback, useEffect } from 'react';

export function DataFetcher() {
  // ✅ useCallback prevents infinite useEffect loops
  const fetchData = useCallback(async () => {
    const response = await fetch('/api/data');
    return response.json();
  }, []);

  useEffect(() => {
    fetchData();
  }, [fetchData]); // Stable callback prevents re-runs

  return <div>Data...</div>;
}

3. Using callback in custom Hooks dependencies

typescript
const memoizedCallback = useCallback(() => {
  // ...
}, [dep1, dep2]);

// useCallback stabilizes the function for other Hooks
useEffect(() => {
  // This won't run on every render
}, [memoizedCallback]);

# ❌ DON'T USE useCallback When

1. Just storing a simple callback (no memoized children)

typescript
export function Form() {
  // ❌ WRONG: useCallback adds overhead without benefit
  const handleSubmit = useCallback((e: React.FormEvent) => {
    console.log('Submitted');
  }, []);

  return <form onSubmit={handleSubmit}>...</form>;
}

// Better: just use a regular function
export function Form() {
  // ✅ CORRECT: no memoization needed
  const handleSubmit = (e: React.FormEvent) => {
    console.log('Submitted');
  };

  return <form onSubmit={handleSubmit}>...</form>;
}

2. Child component isn't memoized

typescript
export function Parent() {
  // ❌ WRONG: useCallback doesn't help if child always re-renders
  const handleClick = useCallback(() => {
    setCount(c => c + 1);
  }, []);

  // Child re-renders whenever Parent renders anyway
  return <Child onClick={handleClick} />;
}

function Child({ onClick }) {
  // Not memoized, so parent's useCallback provides no benefit
  return <button onClick={onClick}>Click</button>;
}

3. Callback is used only in inline event handlers

typescript
export function Counter() {
  const [count, setCount] = useState(0);

  // ❌ WRONG: useCallback not needed for inline handlers
  const increment = useCallback(() => {
    setCount(c => c + 1);
  }, []);

  return <button onClick={increment}>Increment</button>;

  // ✅ Better: just inline it
  return <button onClick={() => setCount(c => c + 1)}>Increment</button>;
}

# Common Mistakes

# Mistake 1: Forgetting Dependencies

typescript
import { useCallback, useState } from 'react';

export function Counter() {
  const [count, setCount] = useState(0);

  // ❌ WRONG: count is used but not in dependency array
  const increment = useCallback(() => {
    console.log(`Current count: ${count}`);
    setCount(count + 1); // Always sets to 1 (stale count)
  }, []); // Missing [count]!

  return (
    <button onClick={increment}>
      Count: {count}
    </button>
  );
}

The problem: The callback closure captures the initial count value (0). It never sees updated values.

The fix:

typescript
export function Counter() {
  const [count, setCount] = useState(0);

  // ✅ CORRECT: count in dependency array
  const increment = useCallback(() => {
    console.log(`Current count: ${count}`);
    setCount(count + 1);
  }, [count]); // count is a dependency

  return <button onClick={increment}>Count: {count}</button>;
}

# Mistake 2: Adding Too Many Dependencies

typescript
export function SearchResults() {
  const [query, setQuery] = useState('');
  const [filters, setFilters] = useState({});
  const [limit, setLimit] = useState(10);

  // ❌ PROBLEMATIC: Too many dependencies = callback recreated often
  const search = useCallback(async () => {
    const results = await fetch(
      `/api/search?q=${query}&filters=${JSON.stringify(filters)}&limit=${limit}`
    );
    return results.json();
  }, [query, filters, limit]); // Every state change recreates function

  // This defeats the purpose of useCallback!
}

When dependencies change frequently, useCallback provides little benefit. This is normal—useCallback isn't always beneficial.

# Mistake 3: Using useCallback Without memo()

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

  // ❌ WRONG: useCallback without memo() on child
  const handleClick = useCallback(() => {
    console.log('Clicked');
  }, []);

  return (
    <div>
      <button onClick={() => setCount(c => c + 1)}>
        Update parent: {count}
      </button>
      {/* Child is NOT memoized, so it re-renders anyway */}
      <Child onClick={handleClick} />
    </div>
  );
}

function Child({ onClick }) {
  // Not wrapped with memo(), so useCallback is wasted
  console.log('Child rendered');
  return <button onClick={onClick}>Child button</button>;
}

// ✅ CORRECT: memoize the child
const Child = memo(function Child({ onClick }) {
  console.log('Child rendered');
  return <button onClick={onClick}>Child button</button>;
});

# Combining useCallback with memo()

# The Pattern

useCallback only works when the child component is memoized:

typescript
import { memo, useCallback, useState } from 'react';

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

  // Step 1: Memoize the callback
  const handleDelete = useCallback((id: string) => {
    console.log(`Deleting ${id}`);
  }, []);

  return (
    <div>
      <button onClick={() => setCount(c => c + 1)}>Count: {count}</button>
      {/* Step 2: Memoize the child */}
      <MemoizedDeleteButton onDelete={handleDelete} />
    </div>
  );
}

interface DeleteButtonProps {
  onDelete: (id: string) => void;
}

// ✅ Both memo() and useCallback together
const MemoizedDeleteButton = memo(function DeleteButton({ onDelete }: DeleteButtonProps) {
  console.log('DeleteButton rendered');
  return <button onClick={() => onDelete('item-1')}>Delete</button>;
});
javascript
export function Parent() {
  const [count, setCount] = useState(0);

  const handleDelete = useCallback((id) => {
    console.log(`Deleting ${id}`);
  }, []);

  return (
    <div>
      <button onClick={() => setCount(c => c + 1)}>Count: {count}</button>
      <MemoizedDeleteButton onDelete={handleDelete} />
    </div>
  );
}

const MemoizedDeleteButton = memo(function DeleteButton({ onDelete }) {
  console.log('DeleteButton rendered');
  return <button onClick={() => onDelete('item-1')}>Delete</button>;
});

Both memo() and useCallback are required for this pattern to work.


# Real-World Patterns

# Pattern 1: Event Handler in List Items

A common scenario: rendering a list of items, each with a delete button:

typescript
import { memo, useCallback, useState } from 'react';

interface Item {
  id: string;
  name: string;
}

export function ItemList() {
  const [items, setItems] = useState<Item[]>([
    { id: '1', name: 'Item 1' },
    { id: '2', name: 'Item 2' },
  ]);

  // ✅ Memoized callback with item id
  const handleDelete = useCallback((itemId: string) => {
    setItems(prev => prev.filter(item => item.id !== itemId));
  }, []);

  return (
    <ul>
      {items.map(item => (
        <MemoizedListItem
          key={item.id}
          item={item}
          onDelete={handleDelete}
        />
      ))}
    </ul>
  );
}

interface ListItemProps {
  item: Item;
  onDelete: (id: string) => void;
}

// ✅ Memoized child component
const MemoizedListItem = memo(function ListItem({ item, onDelete }: ListItemProps) {
  console.log(`ListItem ${item.id} rendered`);
  return (
    <li>
      {item.name}
      <button onClick={() => onDelete(item.id)}>Delete</button>
    </li>
  );
});

Benefit: Each ListItem only re-renders when its own item prop changes, not when other items are deleted.

# Pattern 2: API Call in useEffect

Stabilizing API call functions:

typescript
import { useCallback, useEffect, useState } from 'react';

interface User {
  id: string;
  name: string;
  email: string;
}

export function UserProfile({ userId }: { userId: string }) {
  const [user, setUser] = useState<User | null>(null);
  const [error, setError] = useState<string | null>(null);
  const [isLoading, setIsLoading] = useState(true);

  // ✅ Memoized fetch function
  const fetchUser = useCallback(async () => {
    setIsLoading(true);
    setError(null);
    try {
      const response = await fetch(`/api/users/${userId}`);
      const data = await response.json();
      setUser(data);
    } catch (err) {
      setError(err instanceof Error ? err.message : 'Failed to fetch user');
    } finally {
      setIsLoading(false);
    }
  }, [userId]); // Re-create function when userId changes

  useEffect(() => {
    fetchUser();
  }, [fetchUser]); // Stable dependency for useEffect

  if (isLoading) return <div>Loading...</div>;
  if (error) return <div>Error: {error}</div>;
  return <div>{user?.name}</div>;
}

Benefit: useCallback with userId dependency ensures useEffect runs only when userId changes, not on every render.

# Pattern 3: Form Handling with Validation

Complex forms with async validation:

typescript
import { useCallback, useState } from 'react';

export function RegistrationForm() {
  const [email, setEmail] = useState('');
  const [password, setPassword] = useState('');
  const [emailError, setEmailError] = useState('');
  const [isValidating, setIsValidating] = useState(false);

  // ✅ Memoized validation callback
  const validateEmail = useCallback(async (value: string) => {
    setIsValidating(true);
    try {
      const response = await fetch('/api/check-email', {
        method: 'POST',
        body: JSON.stringify({ email: value }),
      });
      const { available } = await response.json();
      setEmailError(available ? '' : 'Email already registered');
    } finally {
      setIsValidating(false);
    }
  }, []);

  return (
    <form>
      <input
        value={email}
        onChange={(e) => {
          setEmail(e.target.value);
          validateEmail(e.target.value); // Stable function reference
        }}
        disabled={isValidating}
      />
      {emailError && <p>{emailError}</p>}
      <input
        type="password"
        value={password}
        onChange={(e) => setPassword(e.target.value)}
      />
      <button type="submit">Register</button>
    </form>
  );
}

# The React Compiler Change

# What's Happening

React is building a compiler that automatically adds optimizations like useCallback, useMemo, and memo(). Once stable, you might not need to manually add useCallback anymore.

From React Key Concepts:

"Once the React compiler is stable, it will very likely be a standard tool that's part of every React project's build process. Therefore, you won't have to use memo(), useMemo(), or useCallback() manually in your code anymore."

# What This Means Today

  • For now: Still manually use useCallback in production code
  • Soon: The compiler will handle it for you
  • Implication: Don't over-optimize prematurely—focus on correctness first

# FAQ

# Q: Should I wrap all my callbacks with useCallback?

A: No. Only use it when:

  1. The callback is passed to a memoized child component, OR
  2. The callback is used as a dependency in useEffect or other Hooks

Otherwise, you're adding overhead without benefit.

# Q: Does useCallback improve performance if the child component isn't memoized?

A: No. If the child always re-renders anyway, the stable callback reference doesn't help. You need both useCallback AND memo() on the child for this pattern to work.

# Q: Can I use useCallback with async functions?

A: Not directly. useCallback can't wrap an async function as its first argument. Instead:

typescript
// ❌ WRONG
const fetch = useCallback(async () => { /* ... */ }, []);

// ✅ CORRECT
const fetch = useCallback(() => {
  return (async () => { /* ... */ })();
}, []);

// Or use useEffect for async operations

# Q: What if I have many dependencies in useCallback?

A: If the dependency array is large and frequently changes, useCallback provides little benefit. This is fine—it means the callback should recreate frequently. You're using the right tool in the right situation.

# Q: Should I use useCallback in custom Hooks?

A: Often yes. If your custom Hook returns a callback that will be used as a dependency elsewhere:

typescript
function useFetch(url: string) {
  // ✅ Memoize the fetch function
  const fetch = useCallback(async () => {
    const response = await fetch(url);
    return response.json();
  }, [url]);

  return fetch; // Stable across re-renders (unless url changes)
}


Questions? Have you run into situations where useCallback helped or hurt your performance? Share your experiences in the comments. What optimization patterns have you found most useful in large-scale applications?

Sponsored Content

Google AdSense Placeholder

CONTENT SLOT

Sponsored

Google AdSense Placeholder

FOOTER SLOT