Search code examples
reactjsreact-hooksuse-effectreact-contextusecallback

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


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!


Solution

  • 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