React useCallback Not Preventing Re-renders: Why and How to Fix It

July 01, 2026 8 min read 3 views

You added useCallback to a function passed to a child component, but the React DevTools Profiler still shows that child re-rendering on every parent update. The hook is there, the dependency array looks right, and yet nothing changes. This is one of the most common React performance traps, and it usually comes down to a small set of fixable mistakes.

What You'll Learn

  • Why useCallback alone cannot stop child re-renders
  • How unstable dependencies silently break memoization
  • How objects and arrays in props bypass your optimization entirely
  • How to verify whether memoization is actually working
  • Concrete patterns that fix each root cause

How useCallback Is Supposed to Work

useCallback(fn, deps) returns a memoized version of the function you pass in. On subsequent renders, React returns the same function reference as long as none of the values in the dependency array have changed. The goal is referential stability: the child receives the exact same function object, so it doesn't see a changed prop.

The important phrase there is "same function reference." JavaScript compares functions by reference, not by content. A freshly created arrow function is never equal to a previous one, even if the code is byte-for-byte identical. useCallback exists to prevent that new function from being created on each render.

// Without useCallback β€” new function reference on every render
function Parent() {
  const handleClick = () => {
    console.log('clicked');
  };
  return <Child onClick={handleClick} />;
}

// With useCallback β€” same reference as long as deps don't change
function Parent() {
  const handleClick = useCallback(() => {
    console.log('clicked');
  }, []); // empty deps: stable forever
  return <Child onClick={handleClick} />;
}

Why useCallback Alone Does Nothing

This is the most misunderstood point: useCallback by itself does not prevent a child component from re-rendering. React's default behavior is to re-render every child whenever the parent renders, regardless of props. You have to opt the child out of that behavior explicitly.

Think of it in two halves. useCallback keeps the function reference stable. React.memo makes the child skip a render when its props haven't changed. You need both halves working together. If either one is missing or broken, the optimization fails.

Your Child Component Isn't Wrapped in React.memo

Without React.memo, the child re-renders unconditionally when the parent does. Stable props don't matter at all. Wrapping the child is the first thing to check.

// This child WILL re-render on every parent render, no matter what
function Child({ onClick }) {
  console.log('Child rendered');
  return <button onClick={onClick}>Click me</button>;
}

// This child will SKIP re-renders when props haven't changed
const Child = React.memo(function Child({ onClick }) {
  console.log('Child rendered');
  return <button onClick={onClick}>Click me</button>;
});

Once you add React.memo, the child does a shallow comparison of its previous and next props. If the onClick reference is stable (thanks to useCallback), the comparison passes and the render is skipped. If the reference changes, the comparison fails and the child re-renders anyway.

A related issue appears in React Context consumers. If you're passing callbacks through context, check out this guide on stopping unnecessary React Context re-renders β€” the same memoization principles apply there.

Your Dependencies Change on Every Render

useCallback compares each dependency using Object.is, which is essentially strict equality. If any dependency is a new value on each render, React discards the cached function and creates a fresh one β€” defeating the whole point.

The most common culprit is a state or prop that is itself an object or function. Consider this example:

function Parent({ config }) {
  // BAD: if 'config' is a new object on every render, handleSubmit
  // gets recreated every time regardless of useCallback
  const handleSubmit = useCallback(() => {
    sendData(config);
  }, [config]);

  return <Child onSubmit={handleSubmit} />;
}

If the caller of Parent writes <Parent config={{ timeout: 5000 }} />, a brand-new object literal is created on every render. Even though its contents look identical, Object.is returns false because the references differ. The dependency is always "new," so useCallback always regenerates the function.

Fix: Stabilize the Dependency First

The answer is to memoize the dependency itself, or restructure so the dependency is a primitive value.

// Option 1: pass primitives instead of objects
function Parent({ timeout }) {
  const handleSubmit = useCallback(() => {
    sendData({ timeout });
  }, [timeout]); // timeout is a number β€” stable comparison works
  return <Child onSubmit={handleSubmit} />;
}

// Option 2: memoize the object dependency
function Parent({ configProp }) {
  const config = useMemo(() => configProp, [configProp.timeout]);
  const handleSubmit = useCallback(() => {
    sendData(config);
  }, [config]);
  return <Child onSubmit={handleSubmit} />;
}

Object and Array Dependencies That Always Look New

Closely related to the above, but worth calling out separately: any object or array literal in a dependency array is a new reference on every render. This catches people out when they inline options or filter criteria.

// BAD: [1, 2, 3] is a new array every render
const handleFilter = useCallback(
  () => filterItems([1, 2, 3]),
  [[1, 2, 3]] // never equal to the previous [1, 2, 3]
);

// GOOD: define the array outside the component, or memoize it
const ALLOWED_IDS = [1, 2, 3]; // module-level constant, stable reference

function Parent() {
  const handleFilter = useCallback(
    () => filterItems(ALLOWED_IDS),
    [] // ALLOWED_IDS is a stable module-level constant
  );
  return <Child onFilter={handleFilter} />;
}

If the values aren't constant, use useMemo to memoize the array or object, then reference that memoized value in useCallback's dependency array.

Inline Object Props Bypassing Your Memoization

Even when your callback is perfectly stable, a single unstable non-function prop next to it breaks React.memo's shallow comparison. The child will re-render because of that other prop, even though the callback didn't change.

// The memoized callback is stable, but 'style' is new every render
function Parent() {
  const handleClick = useCallback(() => doSomething(), []);

  return (
    <Child
      onClick={handleClick}
      style={{ color: 'red' }} // new object every render β€” kills memoization
    />
  );
}

The fix is to move that object outside the component or memoize it with useMemo:

const LABEL_STYLE = { color: 'red' }; // stable module-level constant

function Parent() {
  const handleClick = useCallback(() => doSomething(), []);
  return <Child onClick={handleClick} style={LABEL_STYLE} />;
}

This is the same class of problem you see with useEffect. If you've run into React useEffect firing more often than expected, unstable references in dependency arrays are usually the common thread.

How to Debug Re-renders Effectively

Before reaching for useCallback, confirm that re-rendering is actually your problem and pinpoint where it comes from. Guessing wastes time.

React DevTools Profiler

Open the React DevTools browser extension, switch to the Profiler tab, and record an interaction. Each component that re-rendered shows up highlighted. Hovering a component tells you why it rendered: prop change, state change, or parent render. This is the fastest way to confirm which component is the culprit and which prop changed.

A Lightweight Why-Did-You-Render Patch

The @welldone-software/why-did-you-render library patches React in development to log re-renders with the exact prop or state that changed. Install it in your dev setup only:

npm install --save-dev @welldone-software/why-did-you-render
// src/wdyr.js β€” import this before React in index.js (dev only)
import React from 'react';

if (process.env.NODE_ENV === 'development') {
  const whyDidYouRender = require('@welldone-software/why-did-you-render');
  whyDidYouRender(React, {
    trackAllPureComponents: true,
  });
}

The console output will tell you which prop changed and show you both the previous and next values. That makes the problem obvious in seconds rather than minutes of guessing.

console.log the Reference

For a zero-dependency check, log the callback itself and watch the reference:

function Parent() {
  const handleClick = useCallback(() => doSomething(), [someValue]);

  // If this logs a different object on every render, useCallback isn't working
  console.log('handleClick ref:', handleClick);

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

If the reference changes on every render, your dependency array has an unstable value. If the reference is stable but the child still re-renders, React.memo is missing or another prop is unstable.

Common Pitfalls to Watch For

  • Forgetting React.memo on the child. useCallback has no effect without it. They always work as a pair.
  • Adding every possible dep to silence the linter, then not realizing one of them is unstable. Use eslint-plugin-react-hooks β€” it catches missing deps but it can't tell you a dep is unstable.
  • Wrapping everything in useCallback by default. Memoization has a cost too: React stores the previous function and runs the dependency comparison on every render. For cheap functions passed to non-memoized children, this is pure overhead. Only apply it where you've confirmed a performance problem.
  • Passing a new callback as a key prop. Changing key unmounts and remounts the child entirely, bypassing memoization. This is rarely intentional.
  • Context consumers still re-rendering despite memoized callbacks. If the context value object itself is recreated on every render, all consumers update regardless of what props you stabilize. Memoize the context value with useMemo.

If you've encountered a similar situation in Flutter where a widget rebuilds every time a parent updates, the same principle of isolating what changed applies β€” the Flutter FutureBuilder rebuild guide walks through a comparable diagnostic process.

And if your debugging instincts are sharp, the same mindset for isolating reference-equality bugs applies to other subtle JavaScript issues. The article on Array.sort() giving wrong results is another example where a small, easy-to-miss detail causes completely unexpected behavior.

Wrapping Up

useCallback is a referential stability tool, not a re-render blocker on its own. When it seems broken, the cause is almost always one of the same handful of issues: the child isn't wrapped in React.memo, a dependency is an unstable object or function reference, or a non-function prop next to the callback is recreated each render.

Here are the concrete steps to take right now:

  1. Open the React DevTools Profiler and confirm which component is re-rendering and why before writing any optimization code.
  2. Verify the child is wrapped in React.memo. If it isn't, add it and test before touching anything else.
  3. Audit every value in your useCallback dependency array. If any is an object, array, or function, stabilize it with useMemo or move it to module scope.
  4. Check every non-function prop passed to the memoized child. A single inline object literal there defeats the entire optimization.
  5. Install why-did-you-render in development if the issue is hard to isolate β€” it removes the guesswork entirely.

Frequently Asked Questions

Does useCallback alone stop a child component from re-rendering in React?

No. useCallback only keeps a function's reference stable between renders. To prevent a child from re-rendering, you also need to wrap the child component in React.memo, which tells React to skip re-rendering when props haven't changed shallowly.

Why does my useCallback function still change reference on every render?

The most common cause is an unstable value in the dependency array β€” usually an object literal, array literal, or another function that gets recreated on each render. Use Object.is semantics: if a dependency isn't the exact same reference as before, React discards the cached function. Stabilize dependencies with useMemo or move constants to module scope.

Can React.memo prevent re-renders if one of the props is an inline object?

No. React.memo does a shallow comparison of all props, so a single inline object like style={{ color: 'red' }} creates a new reference every render and causes the comparison to fail. Move static objects to module scope or memoize them with useMemo.

When should I actually use useCallback, and when is it overkill?

Use useCallback when you are passing a callback to a React.memo-wrapped child component and profiling has confirmed that the child's re-renders are causing a real performance problem. Don't apply it by default to every function β€” the memoization itself has a small cost, and without React.memo on the child it achieves nothing.

How do I find out which prop is causing a memoized child to re-render?

The React DevTools Profiler highlights re-rendered components and shows which prop changed. Alternatively, install the @welldone-software/why-did-you-render library in development mode β€” it logs the previous and next value of any prop that caused a re-render, making the culprit immediately visible.

πŸ“€ Share this article

Sign in to save

Comments (0)

No comments yet. Be the first!

Leave a Comment

Sign in to comment with your profile.

πŸ“¬ Weekly Newsletter

Stay ahead of the curve

Get the best programming tutorials, data analytics tips, and tool reviews delivered to your inbox every week.

No spam. Unsubscribe anytime.