DEV Community

Cover image for Lets not optimize your optimization
Abhishek Pandey
Abhishek Pandey

Posted on

Lets not optimize your optimization

New developers often hear or read that useCallback and useMemo are tools to improve the performance of a React application.

As a result, they start using them everywhere, hoping to optimize their app. But this usually leads to cluttered, overly verbose code, filled with complex dependency arrays—and ironically, it often hurts more than it helps.

In this post, we’ll explore what useCallback and useMemo actually do, when they’re useful, and—more importantly—when to avoid them.

Lets learn about what useCallback and useMemo do:-

useCallback:- "remembers" a function which you passed to it, so React doesn’t make a brand new version of that function every time your component updates.

const handleClick = useCallback(() => {
  console.log("Button clicked");
}, []);
Enter fullscreen mode Exit fullscreen mode

In above snippet, handleClick will remain the same across renders as long as the dependency array ([]) doesn't change. Without useCallback, a new function would be created on every render.

useMemo:- lets React remember (or "cache") the result of a calculation so it doesn’t redo it every time your component updates.

const expensiveValue = useMemo(() => {
  return computeHeavyValue(input);
}, [input]);
Enter fullscreen mode Exit fullscreen mode

Here, computeHeavyValue(input) runs only when input changes. If input is the same on the next render, React reuses the cached result instead of recalculating.

Doing these things(remebering of your values when props are changed) comes with a cost.

The Hidden costs of useCallback and useMemo

The core idea behind using useCallback and useMemo is that memoization isn't free—it's a trade-off. You're spending extra CPU cycles and memory to cache a function or value, with the hope that it will save more expensive computations during future renders. But if not used wisely, the cost of memoizing can outweigh the benefits.

1. memory overhead:- This is the most direct cost. When you use useCallback or useMemo, React must store the memoized function or value in memory between renders.

2. CPU overhead:- Before React can decide whether to use the cached value or re-run the function React must iterate through the dependencies array and perform a shallow comparison of each item with the values from the previous render.

3. Readability and Maintenance:- Memoizing every function or value creates verbose code that is difficult to read and understand. These hooks force developers to manage dependency array carefully.Forgetting or adding a new dependecy can create a subtle bug.

const memoizedFn = useCallback(() => {
  doSomething(a, b);
}, [a]); // Missing b in dependencies — can cause bugs
Enter fullscreen mode Exit fullscreen mode

When not to optimize

Let’s look at a case where using useCallback is unnecessary:

1.

const logEvent = useCallback(() => {
  analytics.track('button_click');
}, []);

<button onClick={logEvent}>Click me</button>
Enter fullscreen mode Exit fullscreen mode

In this example, the logEvent function is simple and is not passed to any memoized child components.
Therefore, using useCallback provides no real benefit and only adds unnecessary complexity.

2.

const UserInput = React.memo(({ onChange }) => {
  const handleChange = useCallback(event => {
    onChange(event.target.value);
  }, [onChange]);

  return <input onChange={handleChange} />;
});
Enter fullscreen mode Exit fullscreen mode

In above example handleChange is always created based on onChange, which is the only prop so when the parent re-renders and passes a new onChange prop, both the React.memo and useCallback will trigger a re-render and recreate handleChange. If onChange hasn't changed, React.memo will prevent re-render, so handleChange isn't recalculated either — even without useCallback. So here useCallback is not used properly.

3.

const _debounce = (func, delay) => { /* returns debounced function */ };

const makeApiCall = (e) => { console.log("Making an API call"); };

const debounce = useCallback(_debounce(makeApiCall, 2000), []);

<input onChange={e => debounce(e.target.value)} />
Enter fullscreen mode Exit fullscreen mode

Despite using useCallback, the function is redefined each time (because the function call is inlined), so all the memoization is pointless and adds CPU cost and memory use.

Now let’s explore at the cases where using useMemo is unnecessary:

  • Memoizing small calculation
const filteredTodos = useMemo(() => {
  return todos.filter(todo => todo.completed);
}, [todos]);
Enter fullscreen mode Exit fullscreen mode

Why it's overkill:- the .filter here is very fast, and inline use of it would be more readable as well. useMemo adds memory overhead and dependency complexity here.

  • Memoizing a JSX Tree
const todoRows = useMemo(() => {
  return todos.map(todo => <ToDoRow key={todo.id} todo={todo} />);
}, [todos]);
Enter fullscreen mode Exit fullscreen mode

useMemo just stores a value — but React will still re-render each child component unless they're memoized with React.memo.

When to Optimize

useCallback, useMemo can be powerful tools—but only when used in the right situations.

  • Optimizing Expensive Computation with useMemo-
const filteredUsers = useMemo(() => {
  // Expensive filtering logic
  return users.filter(user => user.name.toLowerCase().includes(searchTerm.toLowerCase()));
}, [users, searchTerm]);
Enter fullscreen mode Exit fullscreen mode

In the example above, useMemo memoizes the filtered list of users, so the filtering function only runs again if either the users array or the searchTerm changes. This optimization can significantly improve performance by avoiding unnecessary recalculations on every render, keeping your UI responsive even with large datasets.

  • Stable Callback for Child Components with useCallback-
const handleUserSelect = useCallback((userId) => {
  // Logic for selecting a user
  setSelectedUserId(userId);
}, [setSelectedUserId]);
Enter fullscreen mode Exit fullscreen mode

In this example, useCallback memoizes the handleUserSelect function so that its reference only changes if setSelectedUserId changes. This stability prevents child components relying on handleUserSelect from re-rendering unless truly necessary, improving rendering efficiency and overall app performance.

  • useCallback and useMemo Together for Linked Operations-
const filteredUsers = useMemo(() => {
  return users.filter(user => user.name.toLowerCase().includes(searchTerm.toLowerCase()));
}, [users, searchTerm]);

const handleUserSelect = useCallback((userId) => {
  setSelectedUserId(userId);
}, [setSelectedUserId]);

return (
  <UserList users={filteredUsers} onUserSelect={handleUserSelect} />
);

Enter fullscreen mode Exit fullscreen mode

useMemo memoizes the filtered list of users to avoid recalculating the filtered array on every render unless users or searchTerm changes. useCallback memoizes the handleUserSelect function to keep its reference stable, preventing unnecessary re-renders in child components that receive it as a prop.

By combining these two hooks, you ensure that the child component <UserList> receives both stable data and stable callbacks, which helps React skip unnecessary renders, improving performance and keeping your UI responsive.

Summary

useCallback and useMemo are powerful hooks that can boost React app performance but only when applied thoughtfully. Overusing them leads to complex, harder-to-maintain code and sometimes even worse performance.

Always ask yourself:

  • Is the function or value expensive to compute?

  • Is it passed to memoized child components that depend on stable references?

  • Will memoization reduce unnecessary renders or recalculations meaningfully?

If the answer is yes, useCallback and useMemo can be great allies. If not, keeping your code simple and clear often serves you better.

Remember, premature optimization is the root of all evil—optimize only when you know the problem exists!

Top comments (0)