Can I useEffect inside a loop sure to be of constant dimension?

Viewed 85

I have a functional React component (more a proxy to a non-React object) like so:

function ProxyComponent({onEvent1, onEvent2, onEvent3 ...}){
  useEffect(()=>{
    someObject?.on('event1', onEvent2);
    return someObject?.off('event1', onEvent2);
  },[onEvent2, someObject])

  useEffect(()=>{
    someObject?.on('event1', onEvent1);
    return someObject?.off('event1', onEvent1);
  },[onEvent1, someObject])
  .
  .
  .
}

Except that there are a lot of events, and there's an obvious pattern here. So I'd like to do this:

function ProxyComponent(props){
  const events = {
    'event1': props.onEvent1 ?? null,
    ...
    'event20': props.onEvent20 ?? null
  }
  

  for (const [name, handler] of Object.entries(events)) {
    useEffect(()=>{
      handler && someObject?.on(name, handler);
      return handler && someObject?.off(name, handler);
    },[handler, someObject])
  }

}

The rules of hook make it clear that this usage is not supported, but I'd like to know how I can automate this pattern while still being within the rules of hook [This is the main question]

Notes:

  1. The .on and .off methods create network requests so it's best to call them as little as possible.

  2. Moving the loop inside the useEffect creates two complications:

    a. A very long dependency array [onEvent1, ..., onEvent20]. I highly doubt that [...Object.values(events)] works.

    b. Every change in a single handler causes several .offs and undoes it with new .ons again. Apart from the inefficiency, in my particular case, I'd like to avoid this because of note#1.

3 Answers

You can create a helper component, like the following

function Helper({eventName, eventHandler, someObject}) {
    useEffect(() => {
        someObject?.on(eventName, eventHandler);
        return someObject?.off(eventName, eventHandler);
    }, [eventName, eventHandler, someObject])
}

Then, you can call this component inside your ProxyComponent, inside of a loop:

const events = {
    'event1': props.onEvent1 ?? null,
    ...
    'event20': props.onEvent20 ?? null
}
return (
    <>
        {Object.keys(events).map(event => (
            <Helper eventName={event} eventHandler={events[event]} someObject={someObject} />
        ))}
    </>
)

To prevent calling Helper component on each prop change of the parent, you can export it with React.memo

I suspect that calling useEffect inside a loop is OK if the number of iterations is absolutely static. The following doesn't throw an error:

const App = () => {
    for (let i = 0; i < 3; i++) {
      React.useEffect(() => {
        console.log('an effect');
      }, []);
    }
    return 'foo';
};

ReactDOM.createRoot(document.querySelector('.react')).render(<App />)
<script crossorigin src="https://unpkg.com/react@18/umd/react.development.js"></script>
<script crossorigin src="https://unpkg.com/react-dom@18/umd/react-dom.development.js"></script>
<div class='react'></div>

and the point of the rule is:

By following this rule, you ensure that Hooks are called in the same order each time a component renders. That’s what allows React to correctly preserve the state of Hooks between multiple useState and useEffect calls.

which a static loop fulfills. Loops often have conditions that will cause them to execute different numbers of times, which is probably why "Don't use hooks inside loops" is there - but the blanket statement is not quite as precise as React actually requires.

So, going with your

for (const [name, handler] of Object.entries(events)) {
    useEffect(()=>{

could be fine - the only issue would be with linters.

Setting up an alternative approach is possible, just somewhat convoluted. You need to emulate the behavior of useEffect by comparing the current value of something to a previous value, without using hooks in a loop. One option would be to put the handers (passed by props) into state. Each render, go through the props and check for any inequalities with what's in state. If there are, resubscribe.

You can do it in a single effect, but you'll have to memoize and compare dependencies manually:

function ProxyComponent (props) {
  const previousEntriesRef = useRef([]);
  const previousSomeObjectRef = useRef(someObject);

  const eventHandlerMap = {
    'event1': props.onEvent1 ?? null,
    'event2': props.onEvent2 ?? null,
    // etc...
  };

  useEffect(() => {
    const cleanupFns = [];
    const currentEntries = Object.entries(eventHandlerMap);
    const someObjectPrevious = previousSomeObjectRef.current;

    for (const [index, [name, handler]] of currentEntries.entries()) {
      const [namePrevious, handlerPrevious] =
        previousEntriesRef.current[index] ?? [];

      if (
        Object.is(name, namePrevious)
        && Object.is(handler, handlerPrevious)
        && Object.is(someObject, someObjectPrevious)
      ) continue;

      if (!handler) continue;
      someObject?.on(name, handler);
      cleanupFns.push(() => someObject?.off(name, handler));
    }

    previousEntriesRef.current = currentEntries;
    previousSomeObjectRef.current = someObject;

    return () => {
      for (const fn of cleanupFns) fn();
    };
  });
}

Related