Most React performance tutorials end with the same bad advice: wrap everything in useMemo and useCallback and call it a day. If you have followed that advice, you have probably made your application slower. These hooks are not free. Each one allocates memory, compares dependencies, and stores cached values. Used without purpose, they become overhead instead of optimization.

Let’s strip this down to what actually matters.

What Each Hook Really Does

useMemo remembers a value. You hand it a function that does heavy work, and it returns the result. On the next render, if your dependencies have not changed, React skips the calculation and hands back the old result.

useCallback remembers a function. It does not run the function for you. It simply returns the same function instance between renders as long as its dependencies stay the same.

That is the entire difference. One caches a computed value. One caches a reference. Mixing these up leads to code that looks optimized but behaves identically to unwrapped code while costing extra memory.

Why Function Identity Breaks Your Tree

When a component re-renders, React executes the entire function body again. Every variable is recreated. Every inline function gets a brand new address in memory.

In JavaScript, two functions that contain the exact same logic are not equal. () => {} === () => {} evaluates to false. The same rule applies to objects and arrays. If your parent component defines handleSubmit and passes it to a child, that child receives a new prop on every single render. Even if the child is wrapped in React.memo, it cannot tell that the new function does the same thing as the old one. The reference changed, so the child re-renders.

This is the root problem useCallback was built to solve. It is not about speed. It is about stability.

When useMemo Earns Its Keep

You need useMemo when you are performing work that is objectively expensive and you can see a measurable lag.

Think about filtering a massive dataset. If you have a table with tens of thousands of rows and a search input, you might write something like this inside your component:

const visibleRows = rows.filter(r => r.name.includes(query));

Without useMemo, that loop runs on every render. If the user clicks a button that toggles a sidebar, the parent re-renders, and your filter runs again even though rows and query never changed. On a large dataset, that stutter is visible.

useMemo fixes this by pinning the result:

const visibleRows = useMemo(() => {
  return rows.filter(r => r.name.includes(query));
}, [rows, query]);

Now React only re-runs that filter when the dependencies actually change.

The same logic applies to complex mathematical calculations, transforming API responses into chart-friendly formats, or deriving state that would otherwise be recalculated constantly.

There is a second, less obvious use case. If you create an object or array locally and include it in a useEffect dependency array, you can accidentally trigger that effect on every render. Inline objects and arrays get new identities each time, so the effect sees a changed dependency and fires again. Memoizing that object with useMemo keeps the reference stable and lets your effect run only when the underlying data actually changes.

When useCallback Becomes Necessary

useCallback matters most when you are passing handlers into child components that are optimized with React.memo.

Imagine a parent component that holds a counter. It also renders an expensive child list:

function Parent() {
  const [count, setCount] = useState(0);
  
  const handleItemClick = (id) => {
    console.log(id);
  };
  
  return (
    <div>
      <button onClick={() => setCount(c + 1)}>{count}</button>
      <ExpensiveList onItemClick={handleItemClick} />
    </div>
  );
}

Every time count changes, Parent re-renders. A fresh handleItemClick is created. Because ExpensiveList receives a new prop reference, it re-renders too. If ExpensiveList is wrapped in React.memo, that memoization is completely wasted because the function prop changed.

useCallback preserves the reference:

const handleItemClick = useCallback((id) => {
  console.log(id);
}, []);

Now ExpensiveList only re-renders when it truly needs to.

Another critical situation involves useEffect. If an effect subscribes to a function defined inside your component, and that function changes identity every render, the effect will teardown and resubscribe repeatedly. Memoizing the function keeps the effect stable.

The Dependency Array Trap and Stale Closures

Both hooks rely on dependency arrays, and this is where most bugs hide.

如果你从依赖数组中省略了一个变量,你的记忆化函数或值就会捕获该变量的旧版本。这就是所谓的“陈旧闭包”(stale closure)。UI 可能显示的是最新数据,但你的回调函数仍在查看三次渲染前的状态。解决方法很简单,但在代码审查中很容易被忽略:将 Hook 内部使用的每一个可能发生变化的值都包含进去。

运行 react-hooks/exhaustive-deps ESLint 规则。它能捕捉到明显的遗漏。但不要把它当成机器人。要理解为什么每个依赖项都至关重要。

过度优化的隐形成本

初学者往往会给每个函数和每个值都套上这些 Hook,因为这样做感觉很安全。但这种习惯往往会适得其反。

React 必须在内存中存储缓存值。在每次渲染时,它必须遍历你的依赖数组,并使用 Object.is 比较每个项目。这种比较虽然开销很小,但并非完全免费。如果你将像 onClick={() => setOpen(true)} 这样微不足道的事件处理程序包装在 useCallback 中,你实际上是在支付内存和 CPU 开销,去规避一个本可以瞬间完成分配的函数创建过程。

这些 Hook 还会增加代码噪音。被 useMemouseCallback 包裹的代码更难阅读,也更难维护。每一个依赖数组都是一个潜在的、随时可能让你踩坑的陈旧闭包。

真正的经验法则虽然平淡但很有效:先编写普通的逻辑代码。只有当你确信存在问题时才进行优化。使用 React DevTools Profiler 来识别哪些组件开销较大,哪些渲染是浪费资源的。如果一次渲染耗时不到几毫秒,用户根本察觉不到,而你的记忆化操作也毫无意义。

总结

useMemo 用于高开销的值。useCallback 用于稳定的函数引用。这两个 Hook 本身并不会让你的组件渲染得更快;它们的作用是防止不必要的后续工作。先不要使用它们,通过实际工具进行测量,然后在 Profiler 显示瓶颈的地方精准地添加它们。偶尔重新渲染的整洁代码,几乎总是优于过度设计、对一切都进行记忆化的代码。