Skip to content

React useCallback Explained — Stabilize Function References

DodaTech Updated 2026-06-28 6 min read

In this tutorial, you will learn about React useCallback Explained. We cover key concepts, practical examples, and best practices to help you master this topic.

React useCallback memoizes callback functions so they maintain a stable reference between renders, preventing unnecessary re-renders of child components wrapped with React.memo.

What You'll Learn

  • What useCallback does and when to use it
  • How useCallback differs from useMemo
  • How to use useCallback with React.memo
  • Dependency rules for useCallback
  • When NOT to use useCallback

Why It Matters

Without useCallback, every render creates new function instances. Child components using React.memo receive new function references and re-render unnecessarily. useCallback stabilizes references for performance-sensitive paths.

Real-World Use

Durga Antivirus Pro's ThreatTable component receives onSort, onFilter, and onRowClick callbacks. These are memoized with useCallback to prevent re-rendering the entire table when unrelated state changes.

flowchart LR
    A[Parent Re-renders] --> B{useCallback?}
    B -->|Yes| C[Stable Function Ref]
    B -->|No| D[New Function Every Render]
    C --> E[Child Memo Works]
    D --> F[Child Re-renders]
    style A fill:#3b82f6,color:#fff

Basic useCallback

import { useState, useCallback } from "react";
import { memo } from "react";

const SearchInput = memo(function SearchInput({ onSearch }) {
  console.log("SearchInput rendered");
  return (
    <input
      type="text"
      onChange={e => onSearch(e.target.value)}
      placeholder="Type to search..."
    />
  );
});

function App() {
  const [query, setQuery] = useState("");
  const [count, setCount] = useState(0);

  // Without useCallback: new function every render -> SearchInput re-renders
  // With useCallback: stable reference unless query changes
  const handleSearch = useCallback((value) => {
    setQuery(value);
  }, []); // Empty: never changes

  return (
    <div>
      <p>Query: {query}</p>
      <p>Count: {count}</p>
      <button onClick={() => setCount(c => c + 1)}>Increment</button>
      <SearchInput onSearch={handleSearch} />
    </div>
  );
}

Expected output: Incrementing count does NOT re-render SearchInput because handleSearch has a stable reference.

useCallback returns the same function instance between renders unless its dependencies change. It takes a function and a dependency array, similar to useEffect.

useCallback vs useMemo

import { useCallback, useMemo, useState } from "react";

function Example() {
  const [items, setItems] = useState([1, 2, 3]);
  const [filter, setFilter] = useState("");

  // useCallback: returns the function itself (memoized)
  const handleRemove = useCallback((id) => {
    setItems(prev => prev.filter(i => i !== id));
  }, []);

  // useMemo: returns the RESULT of the function
  const filteredItems = useMemo(() => {
    return items.filter(i => i.toString().includes(filter));
  }, [items, filter]);

  return (
    <div>
      <input value={filter} onChange={e => setFilter(e.target.value)} />
      {filteredItems.map(item => (
        <button key={item} onClick={() => handleRemove(item)}>
          Remove {item}
        </button>
      ))}
    </div>
  );
}

Expected output: handleRemove is a stable function. filteredItems is a computed array that only recalculates when items or filter change.

useCallback(fn, deps) is equivalent to useMemo(() => fn, deps). Both memoize, but useCallback returns the function while useMemo returns the result of calling the function.

Common useCallback Patterns

import { useState, useCallback } from "react";

function TodoManager() {
  const [todos, setTodos] = useState([]);
  const [editingId, setEditingId] = useState(null);

  // Stable: no dependencies
  const handleAdd = useCallback((text) => {
    setTodos(prev => [...prev, { id: Date.now(), text, completed: false }]);
  }, []);

  // Changes when editingId changes
  const handleStartEdit = useCallback((id) => {
    setEditingId(id);
  }, []);

  // Changes when editingId changes (depends on editingId)
  const handleSaveEdit = useCallback((id, newText) => {
    setTodos(prev => prev.map(t =>
      t.id === id ? { ...t, text: newText } : t
    ));
    setEditingId(null);
  }, []); // Note: uses functional update to avoid editingId dependency

  // Stable: uses functional update
  const handleDelete = useCallback((id) => {
    setTodos(prev => prev.filter(t => t.id !== id));
  }, []);

  const handleToggle = useCallback((id) => {
    setTodos(prev => prev.map(t =>
      t.id === id ? { ...t, completed: !t.completed } : t
    ));
  }, []);

  return (
    <div>
      {todos.map(todo => (
        <TodoItem
          key={todo.id}
          todo={todo}
          isEditing={editingId === todo.id}
          onStartEdit={handleStartEdit}
          onSaveEdit={handleSaveEdit}
          onDelete={handleDelete}
          onToggle={handleToggle}
        />
      ))}
    </div>
  );
}

Expected output: All callbacks are memoized. The functional updater pattern (setTodos(prev => ...)) avoids adding state to dependency arrays.

Using functional updates lets you keep dependency arrays minimal, which means callbacks change less often and child components re-render less.

useCallback with Custom Hooks

Return memoized callbacks from custom hooks:

import { useState, useCallback } from "react";

function useCrudOperations(initialData = []) {
  const [data, setData] = useState(initialData);

  const add = useCallback((item) => {
    setData(prev => [...prev, { ...item, id: Date.now() }]);
  }, []);

  const update = useCallback((id, updates) => {
    setData(prev => prev.map(item =>
      item.id === id ? { ...item, ...updates } : item
    ));
  }, []);

  const remove = useCallback((id) => {
    setData(prev => prev.filter(item => item.id !== id));
  }, []);

  const reset = useCallback(() => {
    setData(initialData);
  }, [initialData]);

  return { data, add, update, remove, reset };
}

// Usage
function ProductManager() {
  const { data: products, add, update, remove } = useCrudOperations([]);

  // These callbacks are stable across renders
  return (
    <ProductList
      products={products}
      onAdd={add}
      onUpdate={update}
      onDelete={remove}
    />
  );
}

Expected output: The callbacks returned by useCrudOperations maintain stable references, making them safe to pass to memoized components.

Custom hooks that return callbacks should memoize them. Consumers of the hook do not expect the callbacks to change on every render.

When NOT to use useCallback

useCallback adds overhead and is not free:

// OVERKILL: Event handler that does not affect children
function SimpleButton({ label, onClick }) {
  return <button onClick={onClick}>{label}</button>; // Not memoized
}

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

  // UNNECESSARY: SimpleButton is not memoized, so it re-renders anyway
  const handleClick = useCallback(() => {
    setCount(c => c + 1);
  }, []);

  return <SimpleButton label="Click" onClick={handleClick} />;
}

// BETTER: Only use when child is memoized or callback is a dependency
function AppImproved() {
  const [count, setCount] = useState(0);
  const handleClick = () => setCount(c => c + 1);
  return <SimpleButton label="Click" onClick={handleClick} />;
}

Expected output: Both versions work identically. The non-memoized version is simpler and faster for this case.

Use useCallback only when:

  • The callback is passed to a memoized child component
  • The callback is used as a dependency of another hook
  • Profiling shows it improves performance

Common Mistakes

  1. Wrapping every function in useCallback — Adds unnecessary overhead. Only memoize when there is a measurable performance benefit.

  2. Incomplete dependency arrays — Omitting dependencies causes stale closures. The linter (react-hooks/exhaustive-deps) catches this.

  3. Unnecessary dependencies — Including functions or objects in dependencies can cause infinite loops. Use functional updates to avoid state dependencies.

  4. Using useCallback for event handlers on DOM elements — React does not memoize event handlers for native elements. The handler does not affect re-renders.

  5. Forgetting that useCallback does not prevent re-renders alone — It must be paired with React.memo on the child to prevent re-renders.

Practice Questions

  1. What does useCallback do? It returns a memoized version of the callback that only changes if dependencies change.

  2. How is useCallback different from useMemo? useCallback returns the memoized function. useMemo returns the memoized result of a function.

  3. When should you use useCallback? When passing callbacks to memoized child components or when the callback is a dependency of another hook.

  4. What is a stale closure? When a callback references outdated variable values because its dependency array is incomplete.

  5. Does useCallback alone prevent child re-renders? No. The child must also use React.memo to benefit from stable callback references.

Challenge

Profile a ChatApp with message list, input box, and typing indicator. Identify which callbacks cause unnecessary re-renders of the message list. Apply useCallback to stabilize only those that improve measured performance by 20%+.

FAQ

Is useCallback the same as useMemo for functions?

Yes, useCallback(fn, deps) is equivalent to useMemo(() => fn, deps).

Does useCallback prevent the function from recreating?

It prevents creating a new function only if dependencies have not changed. The function still creates on initial render.

Can I omit the dependency array?

No, omitting it recreates the function on every render, defeating the purpose.

Does useCallback work with async functions?

Yes,useCallback(async () => { ... }, []) returns a stable async function.

How do I debug useCallback issues?

Add logging in the callback and check whether the reference changes using useRef comparison.

Built by the developers of DodaTech

Doda Browser, DodaZIP & Durga Antivirus Pro