React: Prevent infinite Loop when calling context-functions in useEffect
Asked Answered
C

1

7

In my react app, I am rendering different instances of <Item> Components and I want them to register/unregister in a Context depending if they are currently mounted or not.

I am doing this with two Contexts (ItemContext provides the registered items, ItemContextSetters provides the functions to register/unregister).

const ItemContext = React.createContext({});
const ItemContextSetters = React.createContext({
  registerItem: (id, data) => undefined,
  unregisterItem: (id) => undefined
});

function ContextController(props) {
  const [items, setItems] = useState({});

  const unregisterItem = useCallback(
    (id) => {
      const itemsUpdate = { ...items };
      delete itemsUpdate[id];
      setItems(itemsUpdate);
    },
    [items]
  );

  const registerItem = useCallback(
    (id, data) => {
      if (items.hasOwnProperty(id) && items[id] === data) {
        return;
      }

      const itemsUpdate = { ...items, [id]: data };
      setItems(itemsUpdate);
    },
    [items]
  );

  return (
    <ItemContext.Provider value={{ items }}>
      <ItemContextSetters.Provider value={{ registerItem, unregisterItem }}>
        {props.children}
      </ItemContextSetters.Provider>
    </ItemContext.Provider>
  );
} 

The <Item> Components should register themselves when they are mounted or their props.data changes and unregister when they are unmounted. So I thought that could be done very cleanly with useEffect:

function Item(props) {
  const itemContextSetters = useContext(ItemContextSetters);

  useEffect(() => {
    itemContextSetters.registerItem(props.id, props.data);

    return () => {
      itemContextSetters.unregisterItem(props.id);
    };
  }, [itemContextSetters, props.id, props.data]);

  ...
}

Full example see this codesandbox

Now, the problem is that this gives me an infinite loop and I don't know how to do it better. The loop is happening like this (I believe):

  • An <Item> calls registerItem
  • In the Context, items is changed and therefore registerItem is re-built (because it depends on [items]
  • This triggers a change in <Item> because itemContextSetters has changed and useEffect is executed again.
  • Also the cleanup effect from the previous render is executed! (As stated here: "React also cleans up effects from the previous render before running the effects next time")
  • This again changes items in the context
  • And so on ...

I really can't think of a clean solution that avoids this problem. Am I misusing any hook or the context api? Can you help me with any general pattern on how write such a register/unregister Context that is called by Components in their useEffect-body and useEffect-cleanup?

Things I'd prefer not to do:

  • Getting rid of the context altogether. In my real App, the structure is more complicated and different components throughout the App need this information so I believe I want to stick to a context
  • Avoiding the hard removal of the <Item> components from the dom (with {renderFirstBlock && ) and use something like a state isHidden instead. In my real App this is currently nothing I can change. My goal is to track data of all existing component instances.

Thank you!

Cyrus answered 30/9, 2020 at 7:55 Comment(5)
I guess itemContextSetters is the name of your useContext(ItemContextSetters). If that's the case, you shouldn't have it as part of your useEffect dependencies.Perilymph
This would result in a linting error: React Hook useEffect has a missing dependency: 'itemContextSetters'. Either include it or remove the dependency array. (react-hooks/exhaustive-deps). And for good reason! Since the effect is using itemContextSetters it must make sure to have the up-to-date versionCyrus
Not necessarily, you can suppress that error.Perilymph
Yes, but that error comes for a good reason. It reminds me that I would introduce bugs if I work on an not up-to-date version of itemContextSetters. (Since it is constructed with useCallback and depends on [items] there will be a new version of it every time items changes)Cyrus
Since what you're after is when the component is mounted, given your question. There's no need for the dependencies.Perilymph
A
11

You can make your setters have stable references, as they don't really need to be dependant on items:

const ItemContext = React.createContext({});
const ItemContextSetters = React.createContext({
  registerItem: (id, data) => undefined,
  unregisterItem: (id) => undefined
});

function ContextController(props) {
  const [items, setItems] = useState({});

  const unregisterItem = useCallback(
    (id) => {
      setItems(currentItems => {
        const itemsUpdate = { ...currentItems };
        delete itemsUpdate[id];
        return itemsUpdate
      });
    },
    []
  );

  const registerItem = useCallback(
    (id, data) => {

      setItems(currentItems => {
        if (currentItems.hasOwnProperty(id) && currentItems[id] === data) {
          return currentItems;
        }
        return { ...currentItems, [id]: data }
      } );
    },
    []
  );
  
  const itemsSetters = useMemo(() => ({ registerItem, unregisterItem }), [registerItem, unregisterItem])

  return (
    <ItemContext.Provider value={{ items }}>
      <ItemContextSetters.Provider value={itemsSetters}>
        {props.children}
      </ItemContextSetters.Provider>
    </ItemContext.Provider>
  );
}

Now your effect should work as expected

Alterant answered 30/9, 2020 at 8:56 Comment(7)
I'd like to mention that your way of updating the items object is the correct approach. The OP also made a mistake in his renderFirstBlock toggle. It should be onClick={() => setRenderFirstBlock(prev => !prev)}Beneficence
Great, thank you! That was exactly what I needed! So far I somehow missed that I can pass a function to a state setter and therefore avoid the dependency to the current state. This will also make a lot of other things way easier for me, thank you! One thing however I struggle to understand in the solution: Why do I need the useMemo? To me that line reads like: Update registerItem and unregisterItem if one of the two changes. How does this help?Cyrus
it prevents the context value from changing whenever you call setItems. Without this the object containing registerItem and unregisterItem will be created on every renderAlterant
I though that useCallback would make sure that registerItem is only re-created if a dependency (which is empty) changes? (But yes, when trying it out I can definetly see that withouth useMemo it doesn't work)Cyrus
I think I got it :D We are memoizing the itemSetters as one unit. In the render function {registerItem, unregisterItem} would express a new object (triggering useEffect in our Item-component), even if registerItem and unregisterItem didn't change. Thank you a lot for you time and help!Cyrus
The best approach to have working code and still es-lint valid. Getting previous state as argument in setState to avoid having state in useCallback dependency is pretty cool! I don't think I would have come to that solution. Thanks a lot dudeNorean
This is really helpful for the case where all the context has is callback functions. Unfortunately, in many cases the context includes both values and callbacks (like a value and a callback to update that value -- reactjs.org/docs/…), and if those values are updated like this, you'll need a new context object reference each time, which means the effect re-runs, causing an infinite re-render. This is my main beef with hooks -- use-cases like this that "just work" with class components are major pain points with useEffect.Goldia

© 2022 - 2024 — McMap. All rights reserved.