2

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>
    </>
);

};

eliasondrej
  • 105
  • 3
  • Why don't you set the useEffect on the content ref itself? When that updates, it would trigger the useEffect and you should have access to the dimension there without delay – szczocik May 21 '22 at 12:22
  • @szczocik Unfortunately, this doesn't work. I have tried to add [content.current, content.current?.scrollHeight] as useEffect dependency, but without success. Yes, the hook is triggered when the ref changes, but at that time a am still getting 0 height. – eliasondrej May 21 '22 at 14:19

2 Answers2

2

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

Mytch
  • 380
  • 1
  • 3
  • 16
  • 1
    Yes, but how does this help to solve the problem ? If useLayoutEffect is fired after all DOM manipulations, I would expect that calling "content?.current?.getBoundingClientRect().height" inside it, will return correct dimensions, not 0. – eliasondrej May 21 '22 at 14:39
  • 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 effect. For example, `const ref = useCallback((node) => setRefState(node) , node )`, which will trigger ONLY after the element has successfully finished mounting, and the refrence will then live in the `refState` hook (assuming we have a `[refState, setRefState] = useState()`, like used in the example). - i've edited my answer to reflect this as well. – Mytch May 21 '22 at 14:47
  • 2
    It is grabbing the ref correctly, the reference to DOM element is good. The problem is getting correct dimensions from the ref. – eliasondrej May 21 '22 at 15:31
  • 1
    @eliasondrej Then the ref is behaving exactly as expected, and it's giving a value based on the moment it's successfully mounted with the rest of the DOM. Where the issue lies is that there's a difference between the content being finished rendering to the DOM, and the content being "painted" to the DOM, if you will. The issue here is that the ref is still going to return it's value as soon as possible, even if the vDOM re-renders surrounding components. The `setTimeout` solution you have will work because it's going to run after everything else, including any side effects of the ref object. – Mytch May 21 '22 at 15:44
0

I had a similar issue when I was trying to get the height of an element via its Ref inside useEffect (I also tried useLayoutEffect). Using the onresize event I was able to see that the height started out greater than it was supposed to be and then changed to the correct value (as seen in the element inspector). The height I was getting in useEffect was the greater value which was incorrect. Using a setTimeout() fixed the issue since the value would update to the correct value by the time the timeout ended.

The problem was caused by having flex-grow: 1 in the parent and the element in question set to height: 100% as the child. I set the parent's height to a percentage and it fixed my problem.

So its seems to be a CSS issue.

Bazz94
  • 1
  • 3