'Avoid runnning an effect hook when Context get updated

I have a component MyContainer which has a state variable (defined via useState hook), defines a context provider to which it passes the state variable as value and contains also 2 children, MySetCtxComponent and MyViewCtxComponent.

MySetCtxComponent can change the value stored in the context invoking a set function which is also passed as part of the context, BUT DOES NOT RENDER it.

MyViewCtxComponent, on the contrary, RENDERS the value stored in the context.

MySetCtxComponent defines also an effect via useEffect hook. This effect is, for instance, used to update the value of the context at a fixed interval of time.

So the code of the 3 components is this

MyContainer

export function MyContainer() {
  const [myContextValue, setMyContextValue] = useState<string>(null);

  const setCtxVal = (newVal: string) => {
    setMyContextValue(newVal);
  };

  return (
    <MyContext.Provider
      value={{ value: myContextValue, setMyContextValue: setCtxVal }}
    >
      <MySetCtxComponent />
      <MyViewCtxComponent />
    </MyContext.Provider>
  );
}

MySetCtxComponent (plus a global varibale to make the example simpler)

let counter = 0;

export function MySetCtxComponent() {
  const myCtx = useContext(MyContext);

  useEffect(() => {
    console.log("=======>>>>>>>>>>>>  Use Effect run in MySetCtxComponent");
    const intervalID = setInterval(() => {
      myCtx.setMyContextValue("New Value " + counter);
      counter++;
    }, 3000);

    return () => clearInterval(intervalID);
  }, [myCtx]);

  return <button onClick={() => (counter = 0)}>Reset</button>;
}

MyViewCtxComponent

export function MyViewCtxComponent() {
  const myCtx = useContext(MyContext);

  return (
    <div>
      This is the value of the contex: {myCtx.value}
    </div>
  );
}

Now my problem is that, in this way, everytime the context is updated the effect of MySetCtxComponent is run again even if this is not at all required since MySetCtxComponent does not need to render when the context is updated. But, if I remove myCtx from the dependency array of the useEffect hook (which prevents the effect hook when the context get updated), then I get an es-lint warning such as React Hook useEffect has a missing dependency: 'myCtx'. Either include it or remove the dependency array react-hooks/exhaustive-deps.

Finally the question: is this a case where it is safe to ignore the warning or do I have a fundamental design error here and maybe should opt to use a store? Consider that the example may look pretty silly, but it is the most stripped down version of a real scenario.

Here a stackblitz to replicate the case



Solution 1:[1]

One pattern for solving this is to split the context in two, providing one context for actions and another for accessing the context value. This allows you to fulfill the expected dependency array of the useEffect correctly, while also not running it unnecessarily when only the context value has changed.

const { useState, createContext, useContext, useEffect, useRef } = React;

const ViewContext = createContext();
const ActionsContext = createContext();

function MyContainer() {
  const [contextState, setContextState] = useState();

  return (
    <ViewContext.Provider value={contextState}>
      <ActionsContext.Provider value={setContextState}>
        <MySetCtxComponent />
        <MyViewCtxComponent />
      </ActionsContext.Provider>
    </ViewContext.Provider>
  )
}

function MySetCtxComponent() {
  const setContextState = useContext(ActionsContext);

  const counter = useRef(0);

  useEffect(() => {
    console.log("=======>>>>>>>>>>>>  Use Effect run in MySetCtxComponent");
    const intervalID = setInterval(() => {
      setContextState("New Value " + counter.current);
      counter.current++;
    }, 1000);

    return () => clearInterval(intervalID);
  }, [setContextState]);

  return <button onClick={() => (counter.current = 0)}>Reset</button>;
}

function MyViewCtxComponent() {
  const contextState = useContext(ViewContext);

  return (
    <div>
      This is the value of the context: {contextState}
    </div>
  );
}

ReactDOM.render(
  <MyContainer />,
  document.getElementById("root")
);
<script src="https://cdnjs.cloudflare.com/ajax/libs/react/16.8.4/umd/react.production.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/react-dom/16.8.4/umd/react-dom.production.min.js"></script>

<div id="root"></div>

Solution 2:[2]

The problem is what you're passing to the useEffect dependency array in MySetCtxComponent. You should only pass the update function as shown below.

However, personally I would destructure out the setter as it's more readable and naturally avoids this issue.

const { useState, createContext, useContext, useEffect, useRef, useCallback } = React;

const MyContext = createContext();

function MyContainer() {
  const [myContextValue, setMyContextValue] = useState(null);
  
  // this function is currently unnecessary, but left in because I assume you change the functions default behvaiour in your real code
  // also this should be wrapped in a useCallback if used
  const setCtxVal = useCallback((newVal: string) => {
    setMyContextValue(newVal);
  }, [setMyContextValue]);

  return (
    <MyContext.Provider value={{ value: myContextValue, setMyContextValue: setCtxVal }}>
      <MySetCtxComponent />
      <MyViewCtxComponent />
    </MyContext.Provider>
  )
}

function MySetCtxComponent() {
  const myCtx = useContext(MyContext);
  // or  const { setMyContextValue } = useContext(MyContext);

  const counter = useRef(0);

  useEffect(() => {
    console.log("=======>>>>>>>>>>>>  Use Effect run in MySetCtxComponent");
    const intervalID = setInterval(() => {
      myCtx.setMyContextValue("New Value " + counter.current);
      // or setMyContextValue("New Value " + counter.current);
      counter.current++;
    }, 1000);

    return () => clearInterval(intervalID);
  }, [myCtx.setMyContextValue, /* or setMyContextValue */]);
  
  return <button onClick={() => (counter.current = 0)}>Reset</button>;
}

function MyViewCtxComponent() {
  const myCtx = useContext(MyContext);

  return (
    <div>
      This is the value of the context: {myCtx.value}
    </div>
  );
}

ReactDOM.render(
  <MyContainer />,
  document.getElementById("root")
);
<script src="https://cdnjs.cloudflare.com/ajax/libs/react/16.8.4/umd/react.production.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/react-dom/16.8.4/umd/react-dom.production.min.js"></script>

<div id="root"></div>

Sources

This article follows the attribution requirements of Stack Overflow and is licensed under CC BY-SA 3.0.

Source: Stack Overflow

Solution Source
Solution 1
Solution 2 thecartesianman