Debugging a React useEffect bug

profile photo
Filip Hric
Developing with a framework like React can add a level of complexity to debugging. Hooks like useEffect can often be a culprit, because it can be configured to only run during certain renders. If you rely on a hook running and it doesn’t, this can cause issues. Here’s a real world example of debugging a useEffect bug in Replay DevTools.

The bug

We received reports that sometimes the tooltip that displays the line hit count would be stuck in a loading state forever. This was an intermittent bug, making it harder to pin down because it couldn’t always be reproduced. Fortunately with replay, once we were able to record the bug, we had a debuggable reproduction to work with.
With a bug like this, there could be a lot of things going on. It could be a backend error, network request related, or the result of something going wrong in the front end. Replay engineer Josh Morrow describes his debugging process below.

The debugging process

Create a reproducible example

This replay captures the bug behavior.
In the replay, we can see the tooltip working properly initially, with line hit counts displayed when hovering over the line of code.
Image without caption
There is a click on a comment, which opens a new source in the replay. In Replay, comments can be linked to a specific line of code, so clicking the comment automatically opens the editor and focuses on the line of code.
After this, the tooltip always shows Loading... on hover.
Image without caption

Understand expected behavior

Replay will fetch the number of times a line of code has executed and display that number in a tool tip when you hover on the line. There is a LineNumberToolTip component that handles this functionality on the front end.
We can see in the replay that the updateBreakpointHitCounts function executes early in the replay, and we can see the tooltip updating with the line hit count as expected. The two hits of this function line up with the application working properly in the UI.
Image without caption
Once we understand how the application is supposed to act, we can investigate the point where it doesn’t behave in this same way.

Isolate the issue

The updateBreakpointHitCounts does not execute after the new source is opened. This clues us in that whatever triggers that function is not working properly.
We can see that setBreakpointHitCounts within the setHoveredLineNumber function does execute at the proper locations, so we can narrow in to functionality between this function and updateBreakpointHitCounts, which doesn’t execute as expected.
Logging the parameters of setBreakpointHitCounts shows that the line number changes, but the source id is the same for the entire replay. This means that even though a different file is open in the editor, the LineNumberTooltip component thinks we are still in the same file as before.
In this video below, Josh outlines how he isolated the issue to the sourceId value.

Tracing the root cause

Now that we have isolated the issue to the source id not updating, we can dig in and investigate why.
Within the LineNumberToolTip component on line 89, we are getting the source from useSelector. By logging the source.id on the next line, we can see that it does update correctly. (Read more about why we log the value on the next line in our documentation here).
However, this updated value is not being passed into setBreakpointHitCounts, because when that function executes, it always uses the stale sourceId.
So what’s going on here? The function setBreakpointHitCounts is being called inside a defined function, setHoveredLineNumber. When you define a function in JavaScript, you create what’s called a closure. This means that the value of sourceId when the function is defined, is what the value will always be inside that particular function definition. (You can read more about closures here). When you have a bug where a particular value is not updating as expected or evaluating as a stale value, one of the first questions to consider if whether you’re dealing with the effect of a closure.
The setHoveredLineNumber function is being called inside a useEffect hook in the LineNumberTooltip component on line 142. This hook calls the setHoveredLineNumber function whenever we hover over a line, but because of the closure created by useEffect, the original sourceId is still being used.

The fix

To dig into what’s happening here — the setHoveredLineNumber function declaration is re-executed every time the component renders. However, the function is being assigned to the editor.codemirror.on hook on line 142 inside the useEffect hook. This creates another closure around the assignment of setHoveredLineNumber with the original sourceId value.
The useEffect hook is configurable, so it may or may not run on every render. In this case, by passing an empty array as the second parameter, we are telling useEffect to only run once when the component is mounted. This means that even if the updated sourceId is passed to the setHoveredLineNumber function in a new declaration, the original assignment with the original value is still being used.
In the fix, we pass source as a value to the array in the second parameter. This means that the useEffect hook will run again whenever the value of source is updated. This way, we always use the correct sourceId when assigning setHoveredLineNumber.
In the video below, Josh digs into the root cause and how it was ultimately resolved.
Replay has React DevTools built in so you can debug issues related to components, props, and state. Check out our documentation here for more examples.
Related posts
post image
In this failure, we look into a bug where a React component calls Math.round and gets a different value when the test passes and fails!
post image
This week we’re excited to share Elements Panel V3, which brings one second element loads.
post image
Today we’re excited to share an entirely new version of React DevTools which Brian Vaughn wrote from scratch.
Powered by Notaku