Antonio Laguna
Antonio Laguna

Reputation: 9292

Frozen closure with useCallback/useMemo

I've been seeing odd behavior in which useCallback function doesn't seem to be able to access outer scope.

This is described here: How does React Hooks useCallback "freezes" the closure?

Although it can be easily re-calculated by adding all things to the array, it does seem counter-intuitive. So I'm wondering what's the best way to handle it.

Here's the code

import React, { useCallback, useState, useEffect, useRef } from 'react';
import classNames from 'classnames';
import useEventListener from '../../hooks/use-event-listener';
import tinybounce from 'tinybounce';
import Scroll from '../../utils/scroll';

export default function DefaultMainPlayer({ children, className }) {
    const [scrollThreshold, setScrollThreshold] = useState(0);
    const [inView, setInView] = useState(true);
    const [didUserClose, setDidUserClose] = useState(false);
    const el = useRef();
    const scrollHandler = useCallback(() => {
        // scrollThreshold and inView get "frozen" unless they're on the array below
        const isVisible = Scroll.getPosition() <= scrollThreshold;

        if (inView !== isVisible) {
            if (inView) {
                setInView(isVisible);
                setDidUserClose(false);
            }
        }
    }, [scrollThreshold, inView]);
    const cx = classNames('main-player', className, {
        'is-fixed': !inView && !didUserClose
    });
    const resizeHandler = useCallback(
        tinybounce(() => setScrollThreshold(Scroll.getElementCoordinates(el.current).bottom), 300),
        []
    );

    // If scroll threshold updates, lets call the scroll handler
    useEffect(scrollHandler, [scrollThreshold]);
    // Call the resize handler once
    useEffect(resizeHandler, []);

    // I'd like the scroll handler to never change since it really doesn't need to
    useEventListener('scroll', scrollHandler);
    useEventListener('resize', resizeHandler);

    return (
        <div className={cx} ref={el}>{children}</div>
    );
}

Here's the code for `use-event-listener``

import { useRef, useEffect } from 'react';

export default function useEventListener(eventName, handler, element = window) {
    const savedHandler = useRef();

    useEffect(() => {
        savedHandler.current = handler;
    }, [handler]);

    useEffect(() => {
        const isSupported = element && element.addEventListener;
        if (!isSupported) return;

        const eventListener = event => savedHandler.current(event);

        // Add event listener
        element.addEventListener(eventName, eventListener);

        // Remove event listener on cleanup
        return () => {
            element.removeEventListener(eventName, eventListener);
        };
    },
    [eventName, element]
    );
};

I'm trying to avoid scrollHandler to be changing all the time because functions aren't equal. I've tried useMemo (returning the function as a value) but the result is the same. Don't get scrollThreshold updated.

Although these two variables could seem "a little" it could easily need more and it just feels wrong.

Is there any way to fix this or approach it differently?

Upvotes: 1

Views: 2284

Answers (1)

Alvaro
Alvaro

Reputation: 9662

The issue seems to be that you are using a state variable inside the callback which is then set inside useEffect. There is probably no simple way to handle this, see here.

Two options


Do not use useCallback. This way when running the function it will use the latest scrollThreshold and inView. Also pass the changing variables to useEventListener, or any hook that uses it, as dependencies of the useEffect (this would need some refactor):

useEffect(() => {
    const isSupported = element && element.addEventListener;
    if (!isSupported) return;

    const eventListener = handler;

    // Add event listener
    element.addEventListener(eventName, eventListener);

    // Remove event listener on cleanup
    return () => {
        element.removeEventListener(eventName, eventListener);
    };
}, [eventName, element, scrollThreshold, inView]);

Use useCallback with scrollThreshold, inView as dependencies. In useEventListener, or any hook that uses it, use the function passed as a dependency in the adding/removing event handler:

useEffect(() => {
    const isSupported = element && element.addEventListener;
    if (!isSupported) return;

    const eventListener = handler;

    // Add event listener
    element.addEventListener(eventName, eventListener);

    // Remove event listener on cleanup
    return () => {
        element.removeEventListener(eventName, eventListener);
    };
}, [eventName, element, handler]);

Let me know if something is not clear.

Upvotes: 1

Related Questions