React useEffect Hook when only one of the effect's deps changes, but not the others

JavascriptReactjsReact Hooks

Javascript Problem Overview


I have a functional component using Hooks:

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

  // In a callback Hook to prevent unnecessary re-renders 
  const handleFetchItems = useCallback(() => {
    fetchItemsFromApi().then(setItems);
  }, []);

  // Fetch items on mount
  useEffect(() => {
    handleFetchItems();
  }, []);

  // I want this effect to run only when 'props.itemId' changes,
  // not when 'items' changes
  useEffect(() => {
    if (items) {
      const item = items.find(item => item.id === props.itemId);
      console.log("Item changed to " item.name);
    }
  }, [ items, props.itemId ])

  // Clicking the button should NOT log anything to console
  return (
    <Button onClick={handleFetchItems}>Fetch items</Button>
  );
}

The component fetches some items on mount and saves them to state.

The component receives an itemId prop (from React Router).

Whenever the props.itemId changes, I want this to trigger an effect, in this case logging it to console.


The problem is that, since the effect is also dependent on items, the effect will also run whenever items changes, for instance when the items are re-fetched by pressing the button.

This can be fixed by storing the previous props.itemId in a separate state variable and comparing the two, but this seems like a hack and adds boilerplate. Using Component classes this is solved by comparing current and previous props in componentDidUpdate, but this is not possible using functional components, which is a requirement for using Hooks.


What is the best way to trigger an effect dependent on multiple parameters, only when one of the parameters change?


PS. Hooks are kind of a new thing, and I think we all are trying our best to figure out how to properly work with them, so if my way of thinking about this seems wrong or awkward to you, please point it out.

Javascript Solutions


Solution 1 - Javascript

The React Team says that the best way to get prev values is to use useRef: https://reactjs.org/docs/hooks-faq.html#how-to-get-the-previous-props-or-state

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

  const prevItemIdRef = useRef();
  useEffect(() => {
    prevItemIdRef.current = props.itemId;
  });
  const prevItemId = prevItemIdRef.current;

  // In a callback Hook to prevent unnecessary re-renders 
  const handleFetchItems = useCallback(() => {
    fetchItemsFromApi().then(setItems);
  }, []);

  // Fetch items on mount
  useEffect(() => {
    handleFetchItems();
  }, []);

  // I want this effect to run only when 'props.itemId' changes,
  // not when 'items' changes
  useEffect(() => {
    if(prevItemId !== props.itemId) {
      console.log('diff itemId');
    }
 
    if (items) {
      const item = items.find(item => item.id === props.itemId);
      console.log("Item changed to " item.name);
    }
  }, [ items, props.itemId ])

  // Clicking the button should NOT log anything to console
  return (
    <Button onClick={handleFetchItems}>Fetch items</Button>
  );
}

I think that this could help in your case.

Note: if you don't need the previous value, another approach is to write one useEffect more for props.itemId

React.useEffect(() => {
  console.log('track changes for itemId');
}, [props.itemId]);

Solution 2 - Javascript

> ⚠️ NOTE: This answer is currently incorrect and could lead to unexpected bugs / side-effects. The useCallback variable would need to be a dependency of the useEffect hook, therefore leading to the same problem as OP was facing. > > I will address it asap

Recently ran into this on a project, and our solution was to move the contents of the useEffect to a callback (memoized in this case) - and adjust the dependencies of both. With your provided code it looks something like this:

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

  const onItemIdChange = useCallback(() => {
    if (items) {
      const item = items.find(item => item.id === props.itemId);
      console.log("Item changed to " item.name);
    }
  }, [items, props.itemId]);

  // I want this effect to run only when 'props.itemId' changes,
  // not when 'items' changes
  useEffect(onItemIdChange, [ props.itemId ]);

  // Clicking the button should NOT log anything to console
  return (
    <Button onClick={handleFetchItems}>Fetch items</Button>
  );
}

So the useEffect just has the ID prop as its dependency, and the callback both the items and the ID.

In fact you could remove the ID dependency from the callback and pass it as a parameter to the onItemIdChange callback:

const onItemIdChange = useCallback((id) => {
  if (items) {
    const item = items.find(item => item.id === id);
    console.log("Item changed to " item.name);
  }
}, [items]);

useEffect(() => {
  onItemIdChange(props.itemId)
}, [ props.itemId ]) 

Solution 3 - Javascript

An easy way out is to write a custom hook to help us with that

// Desired hook
function useCompare (val) {
  const prevVal = usePrevious(val)
  return prevVal !== val
}

// Helper hook
function usePrevious(value) {
  const ref = useRef();
  useEffect(() => {
    ref.current = value;
  });
  return ref.current;
}

and then use it in useEffect

function Component(props) {
  const hasItemIdChanged = useCompare(props.itemId);
  useEffect(() => {
    if(hasItemIdChanged) {
      // …
    }
  }, [props.itemId, hasItemIdChanged])
  return <></>
}

Solution 4 - Javascript

I am a react hooks beginner so this might not be right but I ended up defining a custom hook for this sort of scenario:

const useEffectWhen = (effect, deps, whenDeps) => {
  const whenRef = useRef(whenDeps || []);
  const initial = whenRef.current === whenDeps;
  const whenDepsChanged = initial || !whenRef.current.every((w, i) => w === whenDeps[i]);
  whenRef.current = whenDeps;
  const nullDeps = deps.map(() => null);

  return useEffect(
    whenDepsChanged ? effect : () => {},
    whenDepsChanged ? deps : nullDeps
  );
}

It watches a second array of dependencies (which can be fewer than the useEffect dependencies) for changes & produces the original useEffect if any of these change.

Here's how you could use (and reuse) it in your example instead of useEffect:

// I want this effect to run only when 'props.itemId' changes,
// not when 'items' changes
useEffectWhen(() => {
  if (items) {
    const item = items.find(item => item.id === props.itemId);
    console.log("Item changed to " item.name);
  }
}, [ items, props.itemId ], [props.itemId])

Here's a simplified example of it in action, useEffectWhen will only show up in the console when the id changes, as opposed to useEffect which logs when items or id changes.

This will work without any eslint warnings, but that's mostly because it confuses the eslint rule for exhaustive-deps! You can include useEffectWhen in the eslint rule if you want to make sure you have the deps you need. You'll need this in your package.json:

"eslintConfig": {
  "extends": "react-app",
  "rules": {
    "react-hooks/exhaustive-deps": [
      "warn",
      {
        "additionalHooks": "useEffectWhen"
      }
    ]
  }
},

and optionally this in your .env file for react-scripts to pick it up:

EXTEND_ESLINT=true

Solution 5 - Javascript

Based on the previous answers here and inspired by react-use's useCustomCompareEffect implementation, I went on writing the useGranularEffect hook to solve a similar issue:

// I want this effect to run only when 'props.itemId' changes,
// not when 'items' changes
useGranularEffect(() => {
  if (items) {
    const item = items.find(item => item.id === props.itemId);
    console.log("Item changed to " item.name);
  }
}, [ items ], [ props.itemId ])

implemented as (TypeScript):

export const useGranularEffect = (
  effect: EffectCallback,
  primaryDeps: DependencyList,
  secondaryDeps: DependencyList
) => {
  const ref = useRef<DependencyList>();

  if (!ref.current || !primaryDeps.every((w, i) => Object.is(w, ref.current[i]))) {
    ref.current = [...primaryDeps, ...secondaryDeps];
  }

  // eslint-disable-next-line react-hooks/exhaustive-deps
  return useEffect(effect, ref.current);
};

Try it on codesandbox

The signature of useGranularEffect is the same as useEffect, except that the list of dependencies has been split into two:

  1. primary dependencies: the effect only runs when these dependencies change
  2. secondary dependencies: all the other dependencies used in the effect

In my opinion, it makes the case of running the effect only when certain dependencies change easier to read.

Notes:

  • Unfortunately, there is not linting rule to help you ensure that the two arrays of dependencies are exhaustive, so it is your responsibility to make sure you're not missing any
  • It is safe to ignore the linting warning inside the implementation of useGranularEffect because effect is not an actual dependency (it's the effect function itself) and ref.current contains the list of all dependencies (primary + secondary, which the linter cannot guess)
  • I'm using Object.is to compare dependencies so that it's consistent with the behaviour of useEffect, but feel free to use your own compare function or, better, to add a comparer as argument

UPDATE: useGranularEffect has now be published into the granular-hooks package. So just:

npm install granular-hooks

then

import { useGranularEffect } from 'granular-hooks'

Solution 6 - Javascript

From the example provided, your effect does not depend on items and itemId, but one of the items from the collection.

Yes, you need items and itemId to get that item, but it does not mean you have to specify them in the dependency array.

To make sure it is executed only when the target item changes, you should pass that item to dependency array using the same lookup logic.

useEffect(() => {
  if (items) {
    const item = items.find(item => item.id === props.itemId);
    console.log("Item changed to " item.name);
  }
}, [ items.find(item => item.id === props.itemId) ])

Solution 7 - Javascript

I just tried this myself and it seems to me that you don't need to put things in the useEffect dependency list in order to have their updated versions. Meaning you can just solely put in props.itemId and still use items within the effect.

I created a snippet here to attempt to prove/illustrate this. Let me know if something is wrong.

const Child = React.memo(props => {
  const [items, setItems] = React.useState([]);
  const fetchItems = () => {
    setTimeout(() => {
      setItems((old) => {
        const newItems = [];
        for (let i = 0; i < old.length + 1; i++) {
          newItems.push(i);
        }
        return newItems;
      })
    }, 1000);
  }
  
  React.useEffect(() => {
    console.log('OLD (logs on both buttons) id:', props.id, 'items:', items.length);
  }, [props.id, items]);
  
  React.useEffect(() => {
    console.log('NEW (logs on only the red button) id:', props.id, 'items:', items.length);
  }, [props.id]);

  return (
    <div
      onClick={fetchItems}
      style={{
        width: "200px",
        height: "100px",
        marginTop: "12px",
        backgroundColor: 'orange',
        textAlign: "center"
      }}
    >
      Click me to add a new item!
    </div>
  );
});

const Example = () => {
  const [id, setId] = React.useState(0);

  const updateId = React.useCallback(() => {
    setId(old => old + 1);
  }, []);

  return (
    <div style={{ display: "flex", flexDirection: "row" }}>
      <Child
        id={id}
      />
      <div
        onClick={updateId}
        style={{
          width: "200px",
          height: "100px",
          marginTop: "12px",
          backgroundColor: 'red',
          textAlign: "center"
        }}
      >Click me to update the id</div>
    </div>
  );
};

ReactDOM.render(<Example />, document.getElementById("root"));

<script src="https://cdnjs.cloudflare.com/ajax/libs/react/16.8.3/umd/react.production.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/react-dom/16.8.3/umd/react-dom.production.min.js"></script>

<div id='root' style='width: 100%; height: 100%'>
</div>

Attributions

All content for this solution is sourced from the original question on Stackoverflow.

The content on this page is licensed under the Attribution-ShareAlike 4.0 International (CC BY-SA 4.0) license.

Content TypeOriginal AuthorOriginal Content on Stackoverflow
QuestiondarksmurfView Question on Stackoverflow
Solution 1 - JavascriptDavid TáboasView Answer on Stackoverflow
Solution 2 - JavascriptBrad AdamsView Answer on Stackoverflow
Solution 3 - JavascriptGunar GessnerView Answer on Stackoverflow
Solution 4 - JavascriptJohn LeonardView Answer on Stackoverflow
Solution 5 - JavascriptGyum FoxView Answer on Stackoverflow
Solution 6 - JavascriptjokkaView Answer on Stackoverflow
Solution 7 - JavascriptApplePearPersonView Answer on Stackoverflow