I am stuck with this problem regarding React.
I am building a accordion component, with animated collapsing (via max-height transition). I need the accordion to render in open state depending on "showOpen" prop.
Initially, if showOpen prop is true, the collapsible content's max-height is set to 'unset', so the content is visible. So far so good.
After that I need to get content's real dimensions and set specific max-height value, as transitions won't work with 'unset'. I am using a useLayoutEffect hook, running straight after component mount. ( I am using useLayoutEffect, as it should wait for all the dom changes (rendering children), however, it seems to works in the same way as with useEffect.
Inside the useLayoutEffect, I am not able to get content's real dimensions without the "dirty timeout". I assume, the rendering engine needs some more time to compute content's dimensions, but I thought useLayoutEffect should run after this is finished.
So far, I have tried different approaches using ResizeObserver, onLoad event and using ref callback, but none of this have worked. ResizeObserver and onLoad event were not even called, so it seems that the DOM mutations were really executed before the hook, but somehow the correct dimensions were still missing at that time.
The timeout solutions works, but seems unacceptable to me, as it depens on some magical timeout number.
Is there something I have missed ? Could you please suggest better solution ?
thank you sincerely.
const Accordion = ({ label, showOpen, children }) => {
const [isOpen, setOpenState] = useState(showOpen);
const [height, setHeight] = useState(showOpen ? 'unset' : '0px');
const [chevronRotation, setChevronRotation] = useState<'down' | 'right'>(showOpen ? 'down' : 'right');
const content = useRef<HTMLDivElement>(null);
useLayoutEffect(() => {
console.log('first effect', content?.current?.getBoundingClientRect().height); // returns 0
setTimeout(() => {
console.log(
'timeout effect',
content?.current?.getBoundingClientRect().height // returns correct height
);
}, 50);
}, []);
const toggleAccordion = (): void => {
if (isOpen) {
setOpenState(false);
setHeight('0px');
setChevronRotation('right');
} else {
setOpenState(true);
setHeight(`${filtersContent.current?.scrollHeight}px`);
setChevronRotation('down');
}
};
return (
<>
<div className={classNames(styles.accordionSection)} onClick={toggleAccordion}>
<div role="headline">{label}</div>
<Chevron width={8} fill={'#6f6f6f'} direction={chevronRotation} />
</div>
<div ref={content} style={{ maxHeight: `${height}` }}>
{children}
</div>
</>
);
};
I think what’s important to remember here is how the call stack/queue actually works under the hood.
setTimeout() will be placed in the job queue. Think of the job queue like a line/queue for a ride at an amusement park.
You start from the back and wait for you turn.
Async/await, for example, uses the message queue.
You can think of the message queue like the fast pass of the amusement park; it allows you to go from the exit of one ride to the FRONT of the next ride- that same ride that we just saw setTimeout() go to the back of.
So what’s happening with this code, and how does the queue stuff apply??
The layout effect hook’s behavior mixed with the job queue how it is, is quite interesting. In summary, it means that the layout effect won’t resolve/finish until after EVERYTHING else is done, since the timeout callback will be the very last thing to be run.
Mind you, the useLayoutEffect is completely blocked until after all DOM manipulations, and it fires synchronously as well.
See:
Alternatively, if it's not grabbing the ref correctly, which is odd, this seems like the better solution would be to use a plain useEffect, or even better in my opinion, using useCallback with useState to achieve the safe thing.
For example,
const [refState, setRefState] = useState<React.MutableRefObject<HTMLDivElement | null>>(null)
const cbRef = useCallback((node) => setRefState(node), node)
...
return (
<>
...
<div ref={cbRef} style={{ maxHeight: `${height}` }}>
{children}
</div>
</>
);
The ref now will trigger ONLY after the element has successfully finished mounting, and the reference will then live in the refState hook (assuming we have a [refState, setRefState] = useState(), like used in the example).