Reputation: 9292
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
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