DEV Community

Cover image for I've Been Slowing Down React for 3 Years. Here's What My Code Review Revealed.
Daniel Rusnok
Daniel Rusnok

Posted on • Originally published at levelup.gitconnected.com

I've Been Slowing Down React for 3 Years. Here's What My Code Review Revealed.

I've been writing React for three years. I thought I understood memoization. Then a colleague reviewed my PR and pointed something out: I was wrapping almost every computed variable in useMemo.

Not because I had profiled anything. Not because there was a performance problem. Just because — why not? It felt responsible. He was polite about it. But the message was clear: I was making React do more work, not less.

That conversation sent me down a rabbit hole I should have gone down years ago. Here's what I found.


The cost of memoization

The idea behind useMemo is simple: cache the result of an expensive computation so React doesn't have to redo it on every render.

const sortedList = useMemo(() => {
  return items.sort((a, b) => a.name.localeCompare(b.name));
}, [items]);
Enter fullscreen mode Exit fullscreen mode

Instead of sorting on every render, React stores the result and only recalculates when items changes. Sounds like a no-brainer, right?

Here's the part I was ignoring: useMemo itself has a cost. React stores the cached value, compares dependencies on every render, and manages the cache lifecycle.

For simple computations — overkill.

// This is almost certainly not worth memoizing
const fullName = useMemo(() => {
  return `${user.firstName} ${user.lastName}`;
}, [user.firstName, user.lastName]);

// Just do this
const fullName = `${user.firstName} ${user.lastName}`;
Enter fullscreen mode Exit fullscreen mode

String concatenation is done in microseconds. The memoization wrapper costs more than the computation. I had dozens of these in my codebase. And the worst part? I was proud of them. I thought I was being thorough.


Have a reason for useMemo

Don't reach for useMemo until you have a reason. A reason is either a measurable performance problem, or a referential stability requirement.

Case 1: Expensive computation

If the calculation is genuinely costly — filtering a large dataset, running a complex algorithm, processing a lot of data — memoization makes sense. The key word is measurable. If you haven't profiled it, you don't know it's expensive.

const filteredProducts = useMemo(() => {
  return products
    .filter(p => p.category === selectedCategory)
    .filter(p => p.price >= minPrice && p.price <= maxPrice)
    .sort((a, b) => b.rating - a.rating);
}, [products, selectedCategory, minPrice, maxPrice]);
Enter fullscreen mode Exit fullscreen mode

Case 2: Referential stability

This is the one most developers miss — and it's where useMemo is actually most valuable.

In JavaScript, [] !== [] and {} !== {}. Two arrays or objects with identical contents are not the same reference. This matters because React's dependency comparison is based on reference equality.

// Without useMemo - new object reference on every render
// This will cause child components and useEffect to fire even if nothing changed
const config = { threshold: 0.5, maxItems: 100 };

// With useMemo - stable reference, child only re-renders when values actually change
const config = useMemo(() => ({
  threshold: 0.5,
  maxItems: 100
}), []);
Enter fullscreen mode Exit fullscreen mode

If you're passing an object or array as a prop to a memoized child component, or as a dependency to useEffect, you almost certainly need useMemo to maintain referential stability — not to save computation time.

This was the one I genuinely didn't know. I'd been using useMemo for the wrong reason — saving computation — and accidentally getting the right result sometimes. When I understood referential stability, a lot of confusing bugs from the past suddenly made sense.


Don't interchange it with React.memo

React.memo wraps a component, not a value. It tells React: only re-render this component if its props have changed.

const UserCard = React.memo(({ user, onSelect }) => {
  return (
    <div onClick={() => onSelect(user.id)}>
      {user.name}
    </div>
  );
});
Enter fullscreen mode Exit fullscreen mode

Without React.memo, UserCard re-renders every time its parent re-renders — even if user and onSelect haven't changed. With it, React compares props before deciding whether to re-render.

But here's the catch: React.memo uses shallow comparison. If you pass a new object or function reference on every render — which happens by default — the memoization is useless.

// This breaks React.memo - new function reference on every parent render
<UserCard
  user={user}
  onSelect={(id) => handleSelect(id)}
/>

// This works - stable reference via useCallback
const handleSelectUser = useCallback((id) => handleSelect(id), []);
<UserCard
  user={user}
  onSelect={handleSelectUser}
/>
Enter fullscreen mode Exit fullscreen mode

This is why useMemo, React.memo, and useCallback tend to appear together. They're solving the same underlying problem: preventing unnecessary work caused by reference instability.


useCallback is useMemo for functions

useCallback is essentially useMemo for functions. It memoizes a function reference so it stays stable across renders.

// Without useCallback - new function on every render
const handleSubmit = (values) => {
  submitForm(values);
};

// With useCallback - same reference unless dependencies change
const handleSubmit = useCallback((values) => {
  submitForm(values);
}, [submitForm]);
Enter fullscreen mode Exit fullscreen mode

The main use case: passing callbacks to memoized child components or including them as useEffect dependencies. Without useCallback, you'll break React.memo optimization or trigger infinite useEffect loops.

// Classic infinite loop - handleFetch is a new reference on every render
useEffect(() => {
  handleFetch();
}, [handleFetch]);

// Fixed with useCallback
const handleFetch = useCallback(() => {
  fetchData(userId);
}, [userId]);

useEffect(() => {
  handleFetch();
}, [handleFetch]);
Enter fullscreen mode Exit fullscreen mode

One rule of thumb: if you're writing useCallback on a function that never gets passed anywhere as a prop or dependency, you probably don't need it.


Profile before you optimize

Before reaching for any of these tools, you need to know whether you have a problem. Guessing is how I ended up with memoization everywhere and performance that wasn't actually better.

React DevTools Profiler is the right starting point. Record a user interaction, and it shows you exactly which components re-rendered, why, and how long they took. Look for components that render frequently with no visible reason.

A few patterns that indicate real problems:

  • A component re-renders on every keystroke in a parent input, even though it doesn't use the input value
  • A list re-renders all items when only one changes
  • useEffect fires more often than expected

If you don't see these patterns — or if the render time is under a few milliseconds — memoization probably won't help. The cost of the abstraction outweighs the benefit.


Common mistakes that break memoization silently

Even when you reach for these tools correctly, it's easy to misconfigure them and get none of the benefit.

Inline objects in JSX

// Breaks memoization - new object every render
<Chart options={{ color: 'blue', width: 300 }} />

// Fixed
const chartOptions = useMemo(() => ({ color: 'blue', width: 300 }), []);
<Chart options={chartOptions} />
Enter fullscreen mode Exit fullscreen mode

Missing or incorrect dependencies

// Stale closure bug - reads outdated userId
const fetchUser = useCallback(() => {
  api.get(`/users/${userId}`);
}, []); // userId missing from deps

// Correct
const fetchUser = useCallback(() => {
  api.get(`/users/${userId}`);
}, [userId]);
Enter fullscreen mode Exit fullscreen mode

Memoizing too high up

Wrapping an entire page component in React.memo rarely helps — its props almost always change. Memoization is most effective on leaf components that receive stable props and render often.


Decision framework

For useMemo — Is this computation expensive, or does reference stability matter? If neither — skip it.

For React.memo — Does this component render often? Are its props actually stable? If props change frequently anyway, it adds cost without benefit.

For useCallback — Is this function passed as a prop to a memoized component? Is it a dependency in useEffect? If neither — skip it.


Conclusion

The instinct to wrap everything in useMemo came from a good place: I wanted to write performant code. But premature optimization isn't just a waste of time in React — it actively adds complexity: more dependencies to track, more places for stale closure bugs to hide, more cognitive overhead for anyone reading the code later.

After that review, I went through the codebase and removed about 80% of the useMemo calls I'd written. The app didn't slow down. The code got easier to read. That stung a little.

My colleague's comment was a favor. I just didn't appreciate it until I understood why he was right.


I'm a .NET and React developer sharing what I actually run into building apps at work. This article was originally published on Medium.

Top comments (0)