Indana
← Notes

When

For a while, useCallback felt unnecessary.

I understood what it did—it keeps the same function reference between renders—but I couldn’t really see when it actually matters.

Most examples felt too simple.

A button click here, a small component there.

Nothing that justified the extra complexity.

The Setup That Changed It

Things became clearer when I looked at a more realistic case:

A search page.

  • an input to filter data
  • a large list of items
  • each item is its own component

To keep performance under control, each item is wrapped with React.memo.

const ListItem = React.memo(({ item, onSelect }) => {
  console.log("render item:", item.id)

  return (
    <div onClick={() => onSelect(item)}>
      {item.name}
    </div>
  )
})

The Initial Implementation

In the parent component:

function App() {
  const [query, setQuery] = useState("")
  const [selected, setSelected] = useState(null)

  const items = useMemo(() => {
    return heavyFilter(data, query)
  }, [query])

  return (
    <>
      <input onChange={e => setQuery(e.target.value)} />

      {items.map(item => (
        <ListItem
          key={item.id}
          item={item}
          onSelect={(item) => setSelected(item)}
        />
      ))}
    </>
  )
}

Everything works.

But there’s a subtle issue.

What Actually Happens

Every time you type:

  • <code>App</code> re-renders
  • a new <code>onSelect</code> function is created
  • each <code>ListItem</code> receives a new function prop

Even though:

  • the list items didn’t change
  • only the input value changed

React sees:

“new function → props changed → re-render”

So all items render again.

Why This Is a Problem

If the list is small, you won’t notice.

But with:

  • hundreds of items
  • expensive rendering

This becomes:

unnecessary work on every keystroke

The Fix With useCallback

function App() {
  const [query, setQuery] = useState("")
  const [selected, setSelected] = useState(null)

  const items = useMemo(() => {
    return heavyFilter(data, query)
  }, [query])

  const handleSelect = useCallback((item) => {
    setSelected(item)
  }, [])

  return (
    <>
      <input onChange={e => setQuery(e.target.value)} />

      {items.map(item => (
        <ListItem
          key={item.id}
          item={item}
          onSelect={handleSelect}
        />
      ))}
    </>
  )
}

What Changed

Now:

  • <code>handleSelect</code> stays the same across renders
  • <code>ListItem</code> props don’t change
  • <code>React.memo</code> can actually do its job

So when typing:

  • only the parent re-renders
  • list items stay untouched

The Important Realization

The issue wasn’t about the function itself.

It was about:

the function being treated as a new value every time

Why useCallback Matters Here

Not because the function is expensive.

But because:

its identity affects other components

Without useCallback:

  • identity changes → props change → re-render

With useCallback:

  • identity stays → props stay → skip render

The Shift in Thinking

At first, useCallback feels like:

“avoid recreating functions”

But that’s not the real benefit.

The real benefit is:

controlling when React considers something “the same”

Final Takeaway

You don’t use useCallback for every function.

You use it when:

  • the function is passed to other components
  • those components care about prop identity
  • unnecessary re-renders become a problem

Because in the end, it’s not about functions.

It’s about how React decides:

whether something changed—or not.