A Story of a React Re-Rendering Bug

As front-end developers, we often find ourselves getting into perplexing bugs when the page we build involves a lot of user interactions. When we find a bug, no matter how tricky it is, it means something is wrong in the code. There is no magic, and the code itself does not lie.

This blog will take you on a short journey about how I fixed a particularly annoying bug that existed in one of our products.

How did we learn about the bug?

At Eventbrite, some engineering teams use Rainforest QA to run end-to-end before each release goes out. Our team has set up the scheduled tests to be run against checkout widget, a product that provides a full checkout experience on organizer or partner sites, and we regularly review the test results and see if any bugs have been introduced.

In the beginning, some testers reported an issue, where the result doesn’t match what we described as expectations from test steps. When they clicked on a button on the page, they saw nothing happened from this action, and there were no errors, nor redirects. We were thinking maybe the test itself could be flaky: test steps were not written clearly, or it could be slow network or environment latency. 

However, we saw this particular failure in almost every test run, that unlikely could be due to some flaky tests, we were certain that something must be wrong. After some more investigation, we finally got the steps to reproduce, and found that it had something to do with a specific step, which is to get an input field focused, then click another button. With these steps, we can reproduce the issue consistently.

What was the bug exactly?

In order to show what exactly happened, I recreated the problem in a CodePen link with React and Redux as Javascript libraries. Please note that I simplified the codes in order to demonstrate the problem itself, and they are not the actual codes. The user interface of the example is very simple: only an input field and a button. 

Here is the markup of the input field:

    <input type="text" onFocus={onFocus} onBlur={onBlur} /><span data-mce-type="bookmark" style="display: inline-block; width: 0px; overflow: hidden; line-height: 0;" class="mce_SELRES_start"></span>
  • The input field is a text input, and event handlers for focus and blur events.
  • When it gets focused, it will trigger the “onFocus” handler specified in the app
  • When it gets blurred, it will trigger the “onBlur” handler specified in the app.

The markup of the button is also very straightforward: 

<button onClick={() => console.log('clicked')}>Click Me</button>
  • It has a “click” handler, 
  • It will output “clicked” to console, when the click event gets triggered.

If I click inside the input field to make the element focused, and then click the button on the outside, what would happen? By just looking at the code, it seems pretty clear that we would see “clicked” as output as soon as the button gets clicked.

However, after playing with the CodePen example, it turns out that it was not behaving as we thought. The fact is that we have to click the button twice to see the expected console output. 

What’s going on?

Being able to reproduce the bug is always the first step to fix it, and now, we have a pretty good start by knowing how to trigger this bug. The next step is to investigate what exactly goingis on and find solutions to fix it.

First, let’s look at the component from a high-level view.

class App extends React.Component {
  constructor(props) {
    super(props);
    this.state = {
      isInputFocused: false,
    };
  } 

  _handleInputFocus = () => {...}
  
  _handleInputBlur = () => {...}
    
  render() {
    // other codes
    return (
      <Layout 
        content={(
            <Content onFocus={this._handleInputFocus} onBlur={this._handleInputBlur} isInputFocused={this.state.isInputFocused} />
         )}
        pane={<ConnectedPane />}
      />
    );
  }
}

The consuming app has an internal state called `isInputFocused` and the state value gets changed when the input field receives `focus` and `blur` event.

On the other side, the “ component can also render a side pane, in our case, it’s the `Pane` component which renders the button.

Next, we can look at what this demo app looks like. We also want to observe more about what happens when we interact with the input field and the button. 

By looking at the above Gif, we notice that the DOM element of the pane gets re-rendered as soon as I click outside of the input field, that’s when the “onBlur” event gets triggered from the input field. Interestingly, this also when we click the button right after clicking inside the input field. 

It’s helpful knowing that click event triggered on the button would lead to the same effect of the blur event triggered on the input field. With this information, hypothetically we know the problem can be narrowed down to blur event.

Before we go any deeper, it would be worth to look into DOM events deeper, e.g. onClick and onBlur, so that we can understand the roles they may play in our scenario.

Per Web MDN docs, click event is a full cycle from the moment user press the mouse button (“mousedown”) to release the mouse button (“mouseup”) towards the same element

To better illustrate the scenario, I made this diagram to show the sequence of DOM events and consequent effects to the button on the UI.

The “blur” event, which would cause the element containing our button to re-render, gets triggered right after “mousedown”  event. And due to the re-render, the button that comes out of the re-render is a completely different button than the one in the beginning, even though they look the same visually. In other words, the button before the re-render will receive “mousedown” event, while the button after the re-render will receive “mouseup” event, neither of them receives a “click” event and that’s why clicking the button doesn’t trigger the click event handler at all.

But why does it work if you click the button again? It’s because the “blur” event causing problematic re-render is only happening at the first click. As long as we don’t re-focus the input element again, all subsequent clicks on the button shouldn’t cause any re-render side effect anymore.

How to solve it?

Based on the above investigations, how can we fix it? 

Option 1: Switch onClick to onMouseDown or onMouseUp:

As we know from the diagram above, if we know the button gets re-rendered in the middle of “mousedown” and “mouseup”, what about just use them instead of “click”, will that work?

The answer is Yes, and the reason it works is also mentioned earlier already – the initial button before the re-render will receive “mousedown”, and the button after the re-render will receive “mouseup”. 

If we use “mousedown”:

<button onMouseDown={() => console.log('clicked')}>Click Me</button>

The button before the re-render will react to this “mousedown” event.

If we use “mouseup”:

<button onMouseUp={() => console.log('clicked')}>Click Me</button>

The button after the re-render will react to this “mouseup” event.
Both cases will trigger the event handler properly and the output is displayed correctly.

However, this solution doesn’t seem to solve the root cause, which is the re-render. From all our investigations above, the underlying problem is the “blur” event triggers the re-render, which makes the button click behave not the way we imagined. 

The re-render triggered by blur event is the source of the unexpected side-effects, and it’s totally unnecessary. In almost all complex applications, the DOM tree could get way more complicated than just a dummy button, therefore unnecessary re-render should definitely be avoided.

Option 2: Fix the re-render when “blur” event happened

In our case, you may notice the actual component that was re-rendering is the “. Every time it gets re-rendered, all its children get re-rendered as well, which includes `content` and `pane`. 

We know, by default, one React component will only be re-rendered if its state gets changed, either caused by props change, or triggered by `setState` directly, the process of updating DOM is called Reconciliation. From the observation above, we know the button gets re-rendered unexpectedly, and the button is part of our `Pane` component. 

Please also note that there is “expected” re-render in the example: in the “ component, we explicitly change state value of `isInputFocused`, and as a result, it may re-render the `Content` part due to the prop passed into it may have a different value.

With that in our mind, it helps narrow down the issue down to this prop passed to “ component – `pane`. This component doesn’t have any connection to `isInputFocused`, so it shouldn’t get re-rendered even though `isInputFocused` value changed.

After a deeper look at these two props, we realized the issue is on the `pane` prop where `ConnectedPane` gets passed as value. The `ConnectedPane` is a result of:

const connectPane = (PaneComponent) => (
  connect(
    mapStateToProps,
    mapDispatchToProps
  )(PaneComponent)
);

// connect `Pane` component to store
const ConnectedPane = connectPane(Pane);

Everything seems normal until one thing we noticed that `connectPane(Pane)` was put in the `render` function:

render() {
    const ConnectedPane = connectPane(Pane);
    return (
      
       
<div className="container">
          <Layout 
              content={...}
              pane={<ConnectedPane />}
          />
      </div>


    );
}

From Redux’s doc about connect, it is a wrapper function which will return a wrapped component with some props injected. This means, every time this `render` function invoked, we will get a different wrapped pane component as a prop passed into “ component, and “ component sees this prop is different hence re-render itself.

To avoid this issue and fix our re-render bug eventually, we should create this `ConnectedPane` outside of our `render` function, and make sure our component’s render function to be pure always:

// ConnectedPane is the wrapped `Pane` component and it’s ready to be used.
const ConnectedPane = connectPane(Pane);

class Container extends React.Component {
    render() {
        return (
            <Layout 
                content={...}
                pane={<ConnectedPane />}
            />
        )
    }
}
<span data-mce-type="bookmark" style="display: inline-block; width: 0px; overflow: hidden; line-height: 0;" class="mce_SELRES_start"</span>

 

Fix and verify everything.

Now that we know what the issue was and the fix to it, now let’s apply the fix and see if it would behave as what we expected.

As you can see from the above Gif, the DOM element of “ no longer gets re-rendered, while I interact with the input field by focusing in and out. From the console tab, “clicked” text shows up as soon as I clicked on the button, and there is no more extra click needed.

Conclusion

Fixing a bug is achieved by understanding the problem and finding a solution to address it. To understand the problem, this requires us to figure out its root cause. If we find ourselves using shortcuts or workarounds, it often means we don’t understand the problem well enough. Especially when we realize we’re facing a recurring issue, we should tell ourselves to not give up until finding the root cause.

Sometimes it is challenging if the root cause is not obvious, and it’s quite common when the issue itself is complicated and has other irrelevant and distracting layers. Hence, it’s always a good idea to simplify the complicated problem at first, that could help us recreate the issue quickly, but also dig out the root cause from many reduced layers.

Leave a Reply

Your email address will not be published. Required fields are marked *