This is a blog post for my youtube video https://www.youtube.com/watch?v=XWj6qbX244Y

Let’s enable passive scroll listener in React

How would you do it? Since React doesn’t support by default, you might think about using useEffect() and attach the raw listeners, right?

Something like below

function PassiveScrollContainer({ onScroll, children }) {
  const refScroll = React.useRef();
  useEffect(() => {
    const scrollContainer = refScroll.current;
    if (scrollContainer) {
      scrollContainer.addEventListener("scroll", onScroll, { passive: true });
      return () => {
        scrollContainer.removeEventListener("scroll", onScroll);
      };
    }
  }, []);
  return <div ref={refScroll}>{children}</div>;
}

Above component can be used like this

function App() {
  return (
    <PassiveScrollContainer
      onScroll={() => {
        // do something
      }}
    >
      some content
    </PassiveScrollContainer>
  );
}

So far so good. But what if we want to customize the styling?

Alright, we can just add style to the props

function PassiveScrollContainer({onScroll, children, style}) {
   ...
}

Sounds good. What if we want to add onClick handler to it? what if we need onMouseEnter as well?

We can see the problem here, the options will bloat.

Component level abstraction is not very flexible. Especially when we are trying to create common components.

Reuse behaviors hooks through Ref

Notice the jsx in the previous component we created.

<div ref={refScroll}>{children}</div>

We attached a ref to hold the reference to the DOM node, we can actually use its change as a trigger to attach the event listeners.

Let’s give it a try

function usePassiveScroll({ onScroll }) {
  const refScroll = useRef();
  useEffect(() => {
    const scrollContainer = refScroll.current;
    if (scrollContainer) {
      scrollContainer.addEventListener("scroll", onScroll, { passive: true });
      return () => {
        scrollContainer.removeEventListener("scroll", onScroll);
      };
    }
  }, [refScroll.current, onScroll]);
}

It is used like this.

function App() {
  const onScroll = () => {};
  const refScroll = usePassiveScroll({ onScroll });
  return <div ref={refScroll}>some content</div>;
}

This gives us flexibility since now we can use this behavior in any component without intervening the DOM structure.

What if we want multiple behaviors? No worries, let’s add a new useClick().

function useClick({ onClick }) {
   const refEl = useRef()
   useEffect(() => {
      const el = refEl.current
      if (el) {
         el.addEventListener('click', onClick)
         return () => {
            el.removeEventListener('click', onClick)
         }
      }
   }. [refEl, onClick])
}

All we need to do is try to merge the refs together.

This is not difficult, remember that ref could be a function? We can just create a function which sets the current for each ref.

const mergeRefs = (...refs) => {
   return useCallback((el) => {
      for (const ref of refs) {
         if ('current' in ref) {
            ref.current = el)
         } else if (type of ref === 'function') {
            ref(el)
         } else {
            throw new Error('not ref')
         }
      }
   }, refs)
}

Now both behaviors could be used.

function App() {
  const onScroll = () => {};
  const onClick = () => {};
  const refScroll = usePassiveScroll({ onScroll });
  const refClick = useClick({ onClick });
  const mergedRefs = mergeRefs(refScroll, refClick);
  return <div ref={mergedRefs}>some content</div>;
}

Neat!

Wait, we can do it even better with function refs

useEffect() on ref.current is good, but it is slow, as I explained in this video, function refs is attached at the same phase as commitLayoutEffects, sooner than effect hooks.

Let’s give it a try.

function usePassiveScroll({ onScroll }) {
  const refPrev = useRef();
  const attach = useCallback(
    (el) => {
      el.addEventListener("scroll", onScroll, { passive: true });
      refPrev.current = el;
    },
    [onScroll]
  );
  const dettach = useCallback(
    (e) => {
      refPrev.current.removeEventListener("scroll", onScroll);
      refPrev.current = null;
    },
    [onScroll]
  );

  const ref = (el) => {
    if (el === null) {
      dettach();
    } else {
      attach(e);
    }
  };
  return ref;
}

We need to keep track of the refPrev because when unmounted ref() is called with null.

This version with function ref is cleaner and faster.

useEvent()

Notice how Similar the above 2 hooks are? It is calling us to create a general hook for all events.

function useEvent(event, handler, option = {}) {
  const refPrev = useRef();
  const attach = useCallback(
    (el) => {
      el.addEventListener(event, handler, option);
      refPrev.current = el;
    },
    [handler]
  );

  const dettach = useCallback(
    (el) => {
      refPrev.current.removeEventListener(event, handler);
      refPrev.current = null;
    },
    [handler]
  );

  const ref = (el) => {
    if (el === null) {
      dettach();
    } else {
      attach(el);
    }
  };
  return ref;
}

Now our code becomes even cooler.

function App() {
  const onScroll = () => {};
  const onClick = () => {};
  const refScroll = useEvent('scroll', onScroll,({ onScroll });
  const refClick = useEvent('click', onClick)
  const mergedRefs = mergeRefs(refScroll, refClick);
  return <div ref={mergedRefs}>some content</div>;
}

Looks pretty cool, right? How do you think about this pattern?