An apparent React bug - Phelipe Teles

An apparent React bug

3 min.
View source code

The details HTML element doesn’t seem to work well when used as a controlled component in React, as pointed out in this open GitHub issue. At first, I thought it was a React bug, but at the end of my investigation while writing this, I concluded it’s simply a mistake — not having a single source of truth for state.

Let’s begin by understanding the “bug” itself. Here it is:

import { useState } from 'react'

export default function App() {
  const [isOpen, setIsOpen] = useState(false)

  return (
    <>
      <span>State: {isOpen ? 'open' : 'closed'}</span>

      <details open={isOpen}>
        <summary
          onClick={() => {
            setIsOpen(!isOpen)
          }}
        >
          Summary
        </summary>
        Details
      </details>
    </>
  )
}

Here’s a video showing it next to an HTML inspector:

The details HTML element open attribute is not synchronized with React's state

The isOpen state changes as expected, but the <details> element’s open attribute is not synchronized with it — or rather, it’s in a negative way, as if we’re passing open={!isOpen}.

Why?

It seems React is “doing the right thing”, but in the end things break because the details element has state of its own that React doesn’t know about.

In short, the issue is that there are two sources of truth for the open attribute — React and the browser.

This was masterfully explained in this GitHub comment. It begins by explaining what happens when we click the button the first time:

  1. The onClick event handler of the summary element is triggered, which changes isOpen from false to true.
  2. React re-renders, setting the open attribute of the details element to true.
  3. The default behavior of the details element toggles its open state, reversing it back to false — but React doesn’t know about it.

So that’s how the details element ends up without an open attribute (equivalent to isOpen={false}) while our isOpen state is true — the browser removed it and React doesn’t know about it.

On the second click:

  1. The onClick handler of the summary element is triggered, which toggles isOpen to false.
  2. React re-renders, and finds details already closed, so it doesn’t change it.
  3. The default behavior of the details element toggles its open state again — it was false (closed) so it changes it to true (open), and React has no clue.

Everything is broken.

Workarounds

e.preventDefault()

The fix is to call preventDefault on the onClick event handler of the summary element:

import { useState } from 'react'

export default function App() {
  const [isOpen, setIsOpen] = useState(false)

  return (
    <>
      <span>State: {isOpen ? 'open' : 'closed'}</span>

      <details open={isOpen}>
        <summary
          onClick={(e) => {
            e.preventDefault()
            setIsOpen(!isOpen)
          }}
        >
          Summary
        </summary>
        Details
      </details>
    </>
  )
}

This fixes it because now only React controls the open attribute.

toggle event

Another way is to listen to the toggle event handler, which I learned about thanks to this bug:

import { useState } from 'react'

export default function App() {
  const [isOpen, setIsOpen] = useState(false)

  return (
    <>
      <span>State: {isOpen ? 'open' : 'closed'}</span>

      <details
        open={isOpen}
        onToggle={() => {
          setIsOpen(!isOpen)
        }}
      >
        <summary>Summary</summary>
        Details
      </details>
    </>
  )
}

But unfortunately, this may still cause trouble. For instance, a button that toggles isOpen on click, like the “Toggle details” button below, will cause an infinite loop:

import { useState } from 'react'

export default function App() {
  const [isOpen, setIsOpen] = useState(false)

  return (
    <>
      <span>State: {isOpen ? 'open' : 'closed'}</span>

      <details
        open={isOpen}
        onToggle={() => {
          setIsOpen(!isOpen)
        }}
      >
        <summary>Summary</summary>
        Details
      </details>

      <button onClick={() => setIsOpen(!isOpen)}>Toggle details</button>
    </>
  )
}

Here’s how this happens:

  1. The button onClick event listener toggles isOpen, which changes the open attribute.
  2. The open attribute change fires the toggle event, which executes the onToggle event listener.
  3. The onToggle event listener toggles isOpen, which changes the open attribute — so we’re back to step 2, hence the infinite loop.

What about other frameworks?

I later wondered if other JavaScript frameworks — specifically Solid, Svelte and Vue — could handle this any better, so I tried to reproduce the issue using their “playgrounds” web apps.

Surprisingly, they all have the same issue!

Here are links to playgrounds where the issue is reproduced:

Not a React bug?

In light of this, I don’t think this is an issue with React.

It sure is an application bug, but it’s not React’s fault. The problem is that there are more than a single source of truth controlling the open state, which is asking for trouble.

In fact, all of this suggested to me that it would still happen in vanilla JavaScript. And indeed it does:

document.getElementById('app').innerHTML = `
<span>State: closed</span>

<details>
  <summary>Summary</summary>
  Details
</details>
`

const span = document.querySelector('span')
const details = document.querySelector('details')
const summary = document.querySelector('summary')

let isOpen = false

summary.addEventListener('click', () => {
  if (isOpen) {
    isOpen = false
    span.textContent = 'State: closed'
    details.removeAttribute('open')
    return
  }

  isOpen = true
  span.textContent = 'State: open'
  details.setAttribute('open', '')
})

Closing thoughts

In conclusion, this seems to be a more fundamental issue with how the details element work. For now, there’s no way around it than just get used to immediately calling e.preventDefault() on summary’s onClick event handlers when controlling the details open attribute, which will let React be the single source of truth for its state.