React: HTML Details toggles uncontrollably when starts open
Asked Answered
W

5

6

I'm trying to use the HTML <details> tag to create a simple expandable section using semantic html in combination 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 comes when using React hooks to hold onto whether the <details> panel is open or closed. The basic layout of the React component 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 component 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.

problem

Whopping answered 19/11, 2019 at 20:50 Comment(0)
M
4

I 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.

Metamathematics answered 13/3, 2021 at 16:59 Comment(0)
I
2

I think You should use prevState

<details onToggle={() => toggleOpen(prevOpen => !prevOpen )} open={open}>
Ignatzia answered 19/11, 2019 at 20:58 Comment(1)
Thanks for this, unfortunately this didn't work for me but I think it would generally be better practice for me to use the previous state explicitly inside the toggle open since it's more explicit use of Hooks.Whopping
M
2

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>
  );
};
Mylo answered 19/11, 2019 at 21:43 Comment(2)
Thanks for your answer, unfortunately I stated that I needed a way of keeping track of the details open/closed state for other logic inside the component. But you are right, the details component does work great out of the box!Whopping
This fixed the issue for me, thank you :)Protoplast
A
1

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.

Apocrypha answered 19/11, 2019 at 21:22 Comment(1)
This is a good fix for me, not sure if it's overkill for the element but this is something that will definitely come up in code review!Whopping
C
0

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 compare 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

Charge answered 11/8, 2023 at 15:13 Comment(0)

© 2022 - 2024 — McMap. All rights reserved.