streamich / react-use

React Hooks — 👍
http://streamich.github.io/react-use
The Unlicense
41.77k stars 3.15k forks source link

Many hooks using event listeners should expose ms debounce params #921

Open mikestopcontinues opened 4 years ago

mikestopcontinues commented 4 years ago

Is your feature request related to a problem? Please describe. There's quite a number of hooks in react-use that rely on event listeners. For example, useSize, useWindowScroll, etc. And when relying on them, the frequency of their updates can cause performance issues.

At worst, the result will be unnecessary re-renders, but even in the best case, which requires throttling the output of the offending hooks, you end up with tons of subtree re-computations because the deep hooks still updated.

Describe the solution you'd like Ideally, you want to throttle the event listeners themselves, so that the hooks enclosing them see changes less frequently. The only additional parameter required would be a debounce-in-ms, as we can assume anything that relies on an event listener will benefit from a leading call and trailing calls at a set rate.

Describe alternatives you've considered

  1. Wrapping the output of the hooks in a debounce/throttle. This can only prevent re-renders, but not the potential performance implications of recomputing a subtree's hooks.
  2. Memoizing all neighboring hooks and sub-components. This does reduce recomputing costs down to something unnoticable by (most) end-users, but it adds a ton of cruft to the code, as it can't address the source of the problem.
streamich commented 4 years ago

Also one option could be to use useRafState to update React's state only once per animation frame.

mikestopcontinues commented 4 years ago

Here's how I'm handling the problem for window events...

// import

import {useEffect} from 'react';
import {useRafState} from 'react-use';

import throttle from 'libs/throttle';

// vars

const listenerOpts = {capture: false, passive: true};

// export

export default function useWindowEvents(fn, events = [], ms = 0) {
  const [state, setState] = useRafState(fn());

  useEffect(() => {
    const handler = throttle(() => setState(fn()), ms, ms * 10);

    events.map((e) => window.addEventListener(e, handler, listenerOpts));

    return () => {
      events.map((e) => window.removeEventListener(e, handler));
      handler.cancel();
    };
  }, [fn, events, ms]);

  return state;
}

And here's the throttle fn I'm using, which waits max ms between calls while retriggering, but quickly cleans up within min ms when retriggering ends. I see this as the ideal for event listeners...

// export

export default function throttle(fn, min = 0, max = 0) {
  let id = null;
  let dead = false;
  let lastExec = 0;

  function handler(...args) {
    if (dead) {
      return;
    }

    const elapsed = Date.now() - lastExec;
    const minDiff = Math.max(0, min - elapsed);
    const maxDiff = Math.max(0, max - elapsed);

    clearTimeout(id);

    id = setTimeout(() => {
      fn(...args);
      lastExec = Date.now();
      id = null;
    }, id && max > 0 ? maxDiff > min ? min : maxDiff : minDiff);
  }

  handler.cancel = () => {
    clearTimeout(id);
    dead = true;
  };

  return handler;
}