6

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!

Giraphi
  • 1,383
  • 1
  • 11
  • 26
  • 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. – Joshua Sep 30 '20 at 08:22
  • 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 version – Giraphi Sep 30 '20 at 08:27
  • Not necessarily, you can suppress that error. – Joshua Sep 30 '20 at 08:30
  • 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) – Giraphi Sep 30 '20 at 08:33
  • Since what you're after is when the component is mounted, given your question. There's no need for the dependencies. – Joshua Sep 30 '20 at 08:38

1 Answers1

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

thedude
  • 9,388
  • 1
  • 29
  • 30
  • 2
    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)}` – Scratch'N'Purr Sep 30 '20 at 09:10
  • 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? – Giraphi Sep 30 '20 at 09:33
  • 2
    it prevents the context value from changing whenever you call `setItems`. Without this the object containing `registerItem` and `unregisterItem` will be created on every render – thedude Sep 30 '20 at 09:36
  • 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) – Giraphi Sep 30 '20 at 09:40
  • 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! – Giraphi Sep 30 '20 at 10:08
  • 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 dude – David Voráč Dec 15 '20 at 11:37
  • 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 -- https://reactjs.org/docs/context.html#updating-context-from-a-nested-component), 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`. – Skitterm Apr 28 '21 at 00:38