I'm trying to use the HTML <details>
tag to create a simple expandable section using semantic html in bination with React.
The <details><summary></summary></details>
behaviour works great out of the box and for the 1-2% of my users that use IE users that don't get the show-hide nature of the content, it really isn't the end of the world for the content to always be shown for the time being.
My issue es when using React hooks to hold onto whether the <details>
panel is open or closed. The basic layout of the React ponent is as follows:
const DetailsComponent = ({startOpen}) => {
const [open, toggleOpen] = useState(startOpen);
return (
<details onToggle={() => toggleOpen(!open)} open={open}>
<summary>Summary</summary>
<p>Hidden content hidden content hidden content</p>
</details>
);
};
The reason I need to use the onToggle
event is to update the open
state variable to trigger some other javascript in my real world example. I use the startOpen
prop to decide whether on page render whether the details pane is open or closed.
The expected behaviour happens when I use the ponent as, <DetailsComponent startOpen={ false } />
.
However, when is want to start with the pane open on load (<DetailsComponent startOpen={ true } />
), I can visibly see the pane opening and closing very very quickly over and over again forever.
I'm trying to use the HTML <details>
tag to create a simple expandable section using semantic html in bination with React.
The <details><summary></summary></details>
behaviour works great out of the box and for the 1-2% of my users that use IE users that don't get the show-hide nature of the content, it really isn't the end of the world for the content to always be shown for the time being.
My issue es when using React hooks to hold onto whether the <details>
panel is open or closed. The basic layout of the React ponent is as follows:
const DetailsComponent = ({startOpen}) => {
const [open, toggleOpen] = useState(startOpen);
return (
<details onToggle={() => toggleOpen(!open)} open={open}>
<summary>Summary</summary>
<p>Hidden content hidden content hidden content</p>
</details>
);
};
The reason I need to use the onToggle
event is to update the open
state variable to trigger some other javascript in my real world example. I use the startOpen
prop to decide whether on page render whether the details pane is open or closed.
The expected behaviour happens when I use the ponent as, <DetailsComponent startOpen={ false } />
.
However, when is want to start with the pane open on load (<DetailsComponent startOpen={ true } />
), I can visibly see the pane opening and closing very very quickly over and over again forever.
5 Answers
Reset to default 6I had the same problem. I eventually worked out I can monitor the current state by handling onToggle
and just not use it to set the open
attribute.
export default ({ defaultOpen, summary, children }: DetailsProps): JSX.Element => {
const [expanded, setExpanded] = useState(defaultOpen || false);
return (
<details open={defaultOpen} onToggle={e => setExpanded((e.currentTarget as HTMLDetailsElement).open)}>
<summary>
{expanded ? <ExpandedIcon /> : <CollapsedIcon />}
{summary}
</summary>
{children}
</details>
);
};
Because defaultOpen
does not change it does not cause a DOM update, so the HTML control is still in charge of its state.
I think You should use prevState
<details onToggle={() => toggleOpen(prevOpen => !prevOpen )} open={open}>
The <details>
HTML element does not need to be controlled with js because it already has the functionality to be opened and closed. When you pass the open
attribute and change it in the ontoggle
event, you are creating an endless event loop because the element is toggled then the open state changes which toggles the element which triggers the ontoggle
event and so on... The only thing you need is to pass the initial open state.
const DetailsComponent = ({startOpen}) => {
return (
<details open={startOpen}>
<summary>Summary</summary>
<p>Hidden content hidden content hidden content</p>
</details>
);
};
Seems like onToggle
is called before mount and that's causing an endless loop for the case where it is rendered open. Because that triggers a new toggle event.
One way to avoid it, is to check if the details
tag is mounted and only toggle once it is mounted. That way you're ignoring the first toggle event.
const DetailsComponent = ({ startOpen }) => {
const [open, toggleOpen] = useState(startOpen);
const [isMounted, setMount] = useState(false);
useEffect(() => {
setMount(true);
}, []);
return (
<details onToggle={() => isMounted && toggleOpen(!open)} open={open}>
<summary>Summary</summary>
<p>Hidden content hidden content hidden content</p>
</details>
);
};
You can find a working demo in this Codesandbox.
There are couple of things to consider.
First, when calling onClick
on details HTML element, we are reading the current open state before the change is performed, i.e. we are reading an outdated state.
According to documentation, it is better to use toggle
event which is fired after the open state is changed:
const detailsRef = useRef<HTMLDetailsElement>(null)
const onToggleCallback = useCallback(() => {
console.log(detailsRef.current?.open)
}, [])
useEffect(() => {
detailsRef.current?.addEventListener('toggle', onToggleCallback)
return () => {
detailsRef.current?.removeEventListener('toggle', onToggleCallback)
}
}, [onToggleCallback])
// [...] details
Second, whenever React state isOpen
is changed and this variable is passed down into details
element, the toggle
event is fired again. To prevent changing the React state twice and ending up in a state mismatch, we can simply pare the two values and save a new value into the React state only in case it differs:
const [isOpen, setIsOpen] = useState(true)
// [...] useRef
const onToggleCallback = useCallback(() => {
const newValue = detailsRef.current?.open
if (newValue !== undefined && newValue !== isOpen) {
setIsOpen(newValue)
}
}, [])
// [...] useEffect
<details ref={detailsRef} open={isOpen}>
<summary>Open details</summary>
<div>Details are opened</div>
</details>
Fully working example can be found here https://playcode.io/1559702