React Essentials: Stale State and Hooks Pitfalls

Karim Mokhtar
13 min read1 day ago

--

Introduction

Many frontend engineers tend to overuse useCallback and useMemo, believing they are essential for optimising the performance. While this can backfire, often leading to reduced performance and stale state issues, causing hours of frustrating debugging. In this discussion, we will clarify how these hooks work, when to use them effectively, and how to avoid common pitfalls that can break your application’s performance.

Why Closures Matter in React

Closures are a key concept in JS, and they play important role in how React works, especially when dealing with hooks. Actually, React’s hooks are built using closures. So to understand how hooks behave, it’s crucial to understand what a closure is and why it matters.

What is a Closure?

In a nutshell, a closure is a function that keeps the access to variables from its outer scope, even after that scope has finished executing.

Example:

function createCounter() {
let count = 0; // This variable is inside the outer functionreturn function() { // The inner function forms the closure
count++; // It has access to the `count` variable
console.log(count);
};
}
const counter1 = createCounter(); // Create a new counter
counter1(); // Output: 1
counter1(); // Output: 2
counter1(); // Output: 3
const counter2 = createCounter(); // Create a separate counter
counter2(); // Output: 1 (new counter starts fresh)
counter2(); // Output: 2

As seen in the example, the count variable persists even after the createCounter function has finished execution, allowing each counter instance to maintain its own state. This is possible because the inner function (the closure) still has access to the count variable from the outer function’s scope.

Now that you understand closures, let’s move to a more practical scenario related to React and state management.

Understanding Stale State in React

What is Stale State?

Stale state occurs when a variable or state in your code is not updated as expected, leading to outdated or incorrect values in your application.

Example: Stale State with Delayed Updates

Let’s modify our previous createCounter example to introduce a delayedIncrement function using setTimeout.

function createCounter() {
let count = 0; // This is the state (variable)
return {
increment: function() {
count++;
console.log('Current count:', count);
},
delayedIncrement: function() {
setTimeout(function() {
count++;
console.log('Delayed count:', count); // Might lead to stale state
}, 1000);
},
};
}
const counter = createCounter();
counter.increment(); // Current count: 1
counter.increment(); // Current count: 2

In this example, the delayed increment introduces the potential for stale state. Although the increment function updates the count immediately, the delayedIncrement function can introduce timing issues, where the count variable might not reflect the expected value when accessed after the delay.

This is a common pitfall when working with asynchronous operations in JavaScript, Now let’s move to how is this related to React.

Stale State in React Hooks

In React, stale state can occur in a similar way when state updates are not applied immediately, particularly when dealing with asynchronous functions like setTimeout, promises, or even back-to-back setState calls.

Since the above example is clear and if we applied it to React code we will get the same result. Let’s look at another example and it’s similar to the example from the official React documentation which you might be familiar with but we will explain it with more details:

Example: Stale State with Multiple setCount Calls

const [count, setCount] = useState(0);
const handleIncreaseCount = () => {
// Execution of first click
setCount(count + 1);
console.log("After first setCount:", count); // count = 0 (stale)
setCount(count + 1);
console.log("After second setCount:", count); // count still = 0 (stale)
setCount(count + 1);
console.log("After third setCount:", count); // count still = 0 (stale)
};
return (
<div>
<p>Count: {count}</p>
<button onClick={handleIncreaseCount}>Increment Count</button>
</div>
);

Take a moment to guess here what is happening before continuing reading.
.

.

.

.

What’s Happening Here?

At first glance, it might seem like each setCount call should immediately increment the count and reflect the updated value in the subsequent console.log statements. However, the output shows that count remains 0 throughout the entire function execution.

Why Does This Happen?

This occurs because React batches state updates within a single event loop cycle. The setCount function is not applied immediately; instead, it’s added to a hook queue. React processes this queue only after the function has finished executing, which means that the state is not updated until all setCount calls have been queued.

Let’s break down the execution steps:

1. handleIncreaseCount is invoked.
2. The first setCount(count + 1)
is added to the hook queue. At this point, count is still 0.
3. console.log("After first setCount:", count) executes.
Since the state hasn't been updated yet, count logs as 0.
4. The second setCount(count + 1) is added to the hook queue.
Again, count is still 0.
5. console.log("After second setCount:", count) logs 0 for the same reason.
6. The third setCount(count + 1) is added to the queue,
still using the value count = 0.

Only after the function execution is complete does React process the hook queue, applying the state updates in order. This is why all the console.log statements show stale values.

I have created this basic diagram that could give you a visualised idea about how this is happening.

Execusion steps of the function

React’s Hook Queue and State Updates
To avoid confusion like in the example above, it’s important to remember that:

  • React batches state updates in a single event loop, meaning the state is not updated immediately after calling setState (or setCount in this case).
  • React processes state updates asynchronously, so any console.log or side effects inside the same function will reflect the state as it was before the updates were processed.

How to Fix The above example

To handle stale state correctly, especially in scenarios where you’re performing back-to-back state updates, you should use the functional update form of setState. This ensures that React always uses the most up-to-date state value.

const handleIncreaseCount = () => {
setCount(prevCount => prevCount + 1);
setCount(prevCount => prevCount + 1);
setCount(prevCount => prevCount + 1);
};

This way React will make sure to get the previous state even if it’s pending in the hook queue.

IMPORTANT: the output of the console here will be the same as the previous example as the count is still not updated because of the state update NOT DONE yet. But this way will ensure that at the end of the function execution you will have your expected state value which is 3 here on the first click.

Stale State in Derived Values

In React, it’s common to use state values in other parts of your component logic or to derive new values from them. However, if dependencies are not managed correctly in hooks like useMemo, you may encounter stale state issues where the derived values don’t update as expected.

Example: Stale State in useMemo

Let’s say we want to use the count value to calculate a doubleCount, but we run into a stale state issue due to incorrect dependencies.

const [count, setCount] = useState(0);
const doubleCount = useMemo(() => {
return count * 2;
}, []); // Empty dependency array
const handleIncrement = () => {
setCount(count + 1);
};
return (
<div>
<p>Count: {count}</p>
<p>Double Count: {doubleCount}</p> {/* Stale value */}
<button onClick={handleIncrement}>Increment Count</button>
</div>
);

What’s Happening Here?

The useMemo hook is used to calculate doubleCount based on the count. However, since the dependency array for useMemo is empty ([]), React only computes doubleCount once, on the initial render. As a result, when count changes, the doubleCount value remains stale and does not update.

This might look a naive example for you, but unfortunately many people make this mistake by ignoring one or more of the required dependencies.

TIP: to avoid this kind of dangerous mistakes, consider using eslint-plugin-react-hooks

Back to our example:

Why Does This Really Happen?

The empty dependency array means that useMemo doesn’t re-run the calculation when count changes. Therefore, the derived value doubleCount is “stuck” with the initial value of count which is zero in this case.

Fixing The Stale State

As you might have thought, to fix this issue, we need to ensure that useMemo re-calculates doubleCount whenever count changes. This can be done by adding countto the dependency array of useMemo.

const doubleCount = useMemo(() => {
return count * 2;
}, [count]); // Add "count" to the dependency array

Now, every time count is updated, useMemo will re-run the calculation, ensuring that doubleCount is derived correctly based on the latest value of count.

NOTE: It’s not the correct space to use useMemo but this is for explaining the concept, we will see soon when and how we could use useMemo and useCallback in the correct way.

Other Causes of Stale State

As we’ve now seen in multiple examples, stale state can occur not just in direct state updates but also in derived values or computations, especially when dependencies are not properly tracked in hooks like useMemo or useEffect.

There are many other scenarios where stale state can arise, but we’ve covered enough examples for this article. Let’s now move to our next topic.

Do you really need to use useCallback and useMemo:

Overusing useCallback and useMemo can introduce performance and memory issues in certain cases. While they are designed to optimise React rendering, they can backfire if misused. Here’s how:

1. Memory Overhead

Both useCallback and useMemo cache the function or value, and caching involves storing references in memory.

Every time you use useCallback or useMemo, React keeps a reference to the memoized function or value in memory until the dependencies change. If you overuse these hooks, particularly for values or functions that don’t need memoization, you’re unnecessarily increasing the memory footprint.

Imagine a component where every small function or computed value is wrapped in useCallback or useMemo. React now has to store these memoized values in memory, which increases memory usage over time, especially in large applications.

2. Performance Overhead (Computation Cost)

useCallback and useMemo add computation overhead because React must compare the dependencies on each render to decide whether to reuse the memoized result.

In every re-render, React must check whether the dependencies have changed. For simple dependencies, this cost is negligible, but if you have complex or frequently changing dependencies, this comparison can become costly and negate the benefit of memoization.

In a large component tree with lots of memoized values, React needs to track and compare dependencies during every render cycle. If the dependencies are frequently changing, it may take longer to evaluate whether the memoized function or value can be reused.

3. Increased Garbage Collection

As a normal result, If you overuse memoization, you may end up with many cached values or functions that need to be garbage collected when they are no longer in use (e.g., when a component unmounts or when the dependencies change). The frequency and volume of memoized data can increase the load on the garbage collector, leading to potential performance degradation due to increased garbage collection cycles.

We could explore additional examples where misusing these hooks leads to undesirable outcomes, but the key concepts should be clear by now. With that in mind, let’s shift our focus to the next question.

When Not to Use useCallback and useMemo

Three things you need to put in mind and avoid using these hooks while having them:

  • Trivial Computations: If a calculation or function is fast and doesn’t rely on expensive resources, memoizing it adds unnecessary complexity.
  • Low-Frequency Re-renders: If the component doesn’t re-render often, the cost of recalculating values or re-creating functions is often cheaper than memoizing them.
  • Small Components: Memoization overhead is rarely needed in small or simple components where the cost of recalculating functions or values is negligible.

When to Use useCallback and useMemo

This time we will talk about each hook individually as each of them has its own use cases but keep in mind that they both have the same usage.

If you have read the great react docs you will notice that there are several usages of useCallback and useMemo hooks:

Common usage:

  • Skipping re-rendering of components
  • Preventing an Effect from firing too often

useCallback related:

  • Updating state from a memoized callback (skipped — this is a simpler use case and often more straightforward)
  • Optimizing a custom Hook

useMemo related:

  • Skipping expensive recalculation
  • Memoizing a dependency (skipped — this is a simpler use case and often more straightforward)
  • Memoizing a function (skipped — as not commonly used)

Common usage

NOTE: We will take each part of them with examples to make it easier to remember and understand.

As you might know React is using Object.is for its dependencies comparison.
So if you tried to redefine a function this will result in a new object which obviously is not equal to the old object for the previous function definition.

NOTE: To reduce the reading time we will use useCallback as an example here.

Skipping re-rendering of components

If you are sending a function as prop to a child component and you want to avoid re-rendering this component when the parent re-render this is one of the valid cases to use the useCallback hook, the next example shows a simple code for this approach.

NOTE: to be able to do that using useCallback you have to use React.memo in the child component

Let’s assume this is our child component:

function Child({ count, increment }) {
return (
<div>
<p>Child Count: {count}</p>
<button onClick={increment}>Increment Child Count</button>
</div>
);
}

And the Parent component:

function Parent() {
const [count, setCount] = useState(0);
// Another state that can trigger re-renders of the children components
const [otherState, setOtherState] = useState(false);
const increment = () => setCount(count + 1);
return (
<div>
<p>Count: {count}</p>
<button onClick={() => setOtherState(!otherState)}>
Toggle Other State
</button>
<Child count={count} increment={increment} />
</div>
);
}

In this example if we trigger any update for the otherState the Parent component will re-render causing our Child component to re-render as well even if the count value has not changed.

So how to solve this? As you guessed, we need to use the useCallback & React.memo combination here to make sure the passed increment function will be the same.

Reimplementing the child component:

const MemoizedChild = React.memo(({ count, increment }) => {
return (
<div>
<p>Child Count: {count}</p>
<button onClick={increment}>Increment Child Count</button>
</div>
);
});

And the parent using use:

function Parent() {
const [count, setCount] = useState(0);
const [otherState, setOtherState] = useState(false);
// Memoize the increment function
const increment = useCallback(() => setCount(count + 1), [count]);
return (
<div>
<p>Count: {count}</p>
<button onClick={() => setOtherState(!otherState)}>
Toggle Other State
</button>
<MemoizedChild count={count} increment={increment} />
</div>
);
}

NOTE: you could use setCount(prevCount => prevCount + 1) and add [] empty dependency array

With this approach you make sure that your component will never re-render if the count prop not changed.

Preventing an Effect from firing too often
Our next use case is kinda similar to the above one but related to hooks instead of child components.

So again using Object.is for dependencies comparison if we have a function being used inside a useEffect and passed to its dependencies on each re-render this hook will re-fire even if the other dependencies are not changed.

function Ecommerce() {
const [page, setPage] = useState(0);
const nextPage = () => setPage(prevPage => prevPage + 1);

// This function is redefined on every render, causing useEffect to re-run
const getPageUrl = () => ({
page,
productTypes: "accessories"
});

useEffect(() => {
const options = getPageUrl();
ecommercAPI(options); // assume we have this API call
}, [getPageUrl]);

return (
<div>
<p>Current page: {page}</p>
<button onClick={nextPage}>Next page</button>
Product list...
</div>
);
}

As you might know this approach will re-fire the useHook each re-render even if the page has not changed.

The correct usage for this is to wrap the getPageUrl function with useCallback.

function Ecommerce() {
const [page, setPage] = useState(0);
const nextPage = () => setPage(prevPage => prevPage + 1);
// Memoize the function to ensure it only changes when `page` changes
const getPageUrl = useCallback(() => ({
page,
productTypes: "accessories"
}), [page]);
useEffect(() => {
const options = getPageUrl();
ecommercAPI(options);
}, [getPageUrl]);
return (
<div>
<p>Current page: {page}</p>
<button onClick={nextPage}>Next page</button>
Product list...
</div>
);
}

Now the useEffect will never re-fire even if the component re-rendered for any reason except for the page value change.

useCallback

Optimizing a custom Hook

When you create a custom hook, the functions you return can be passed down to child components as props. If these functions are recreated on every render, it may lead to inefficient rendering and performance issues. By wrapping these functions in useCallback, you can maintain a stable function reference across renders, thus preventing child components from re-rendering unless the relevant state or props change.

function useCounter(initialValue = 0) {
const [count, setCount] = useState(initialValue);
// Memoize the increment function to prevent unnecessary re-renders
const increment = useCallback(() => {
setCount(prevCount => prevCount + 1);
}, []); // No dependencies, so the function remains stable
return { count, increment };
}

In this example, the useCounter hook returns a stable increment function. This means that any component using useCounter will not re-render unnecessarily when other state changes occur within that component. The increment function is stable, ensuring that the child components remain optimised.

useMemo

The main purpose of useMemo is to memoize the expensive calculations instead of recalculating it each time.

Skipping expensive recalculations
Let’s imagine we have a component that needs to perform a computationally expensive operation — such as calculating prime numbers based on a given number of iterations. Without useMemo, the calculation would be repeated on every render, even if the inputs haven’t changed.

function PrimeCalculator() {
const [limit, setLimit] = useState(10000);
const [otherState, setOtherState] = useState(false); // Unrelated state
// Memoize the result of the expensive calculation to avoid recalculating on every render
const primeNumbers = useMemo(() => findPrimeNumbers(limit), [limit]);
return (
<div>
<h3>Prime Numbers up to {limit}:</h3>
<p>{primeNumbers.join(', ')}</p>
<button onClick={() => setLimit(limit + 1000)}>Increase Limit</button>
{/* Unrelated button that doesn't affect prime calculation */}
<button onClick={() => setOtherState(!otherState)}>
Toggle Other State
</button>
</div>
);
}

This way if our limit is not changed we don’t need to recalculate all the prime numbers again even if the component is re-rendered.

Conclusion

Understanding closures and the nuances of stale state is key to using React effectively. React’s batching mechanism and asynchronous nature mean state updates may not be reflected immediately, causing stale values if not handled correctly. Using functional updates and managing dependencies in hooks like useMemo and useCallback ensures that state remains consistent and up-to-date. Additionally, while hooks like useCallback and useMemo offer optimisations, use them thoughtfully to avoid performance drawbacks.

--

--