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
onClickevent handler of thesummaryelement is triggered, which changesisOpenfromfalsetotrue. - React re-renders, setting the
openattribute of thedetailselement totrue. - The default behavior of the
detailselement toggles itsopenstate, 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
onClickhandler of thesummaryelement is triggered, which togglesisOpentofalse. - React re-renders, and finds
detailsalready closed, so it doesn’t change it. - The default behavior of the
detailselement toggles itsopenstate 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
onClickevent listener togglesisOpen, which changes theopenattribute. - The
openattribute change fires thetoggleevent, which executes theonToggleevent listener. - The
onToggleevent listener togglesisOpen, which changes theopenattribute — 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.