5

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

Joel Biffin
  • 348
  • 1
  • 6
  • 18

5 Answers5

2

I think You should use prevState

<details onToggle={() => toggleOpen(prevOpen => !prevOpen )} open={open}>
Bartosz
  • 86
  • 4
  • 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. – Joel Biffin Nov 20 '19 at 08:37
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>
  );
};
Dimitri L.
  • 4,499
  • 1
  • 15
  • 19
  • 1
    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! – Joel Biffin Nov 20 '19 at 08:35
  • This fixed the issue for me, thank you :) – Marcos de Andrade Oct 08 '21 at 22:55
2

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.

pdc
  • 2,314
  • 20
  • 28
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.

AWolf
  • 8,770
  • 5
  • 33
  • 39
  • 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! – Joel Biffin Nov 20 '19 at 08:37
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

Knut Holm
  • 3,988
  • 4
  • 32
  • 54