An apparent React bug
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 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:
- The
onClick
event handler of thesummary
element is triggered, which changesisOpen
fromfalse
totrue
. - React re-renders, setting the
open
attribute of thedetails
element totrue
. - The default behavior of the
details
element toggles itsopen
state, reversing it back tofalse
— 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:
- The
onClick
handler of thesummary
element is triggered, which togglesisOpen
tofalse
. - React re-renders, and finds
details
already closed, so it doesn’t change it. - The default behavior of the
details
element toggles itsopen
state again — it wasfalse
(closed) so it changes it totrue
(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:
- The button
onClick
event listener togglesisOpen
, which changes theopen
attribute. - The
open
attribute change fires thetoggle
event, which executes theonToggle
event listener. - The
onToggle
event listener togglesisOpen
, which changes theopen
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.