63

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.

darksmurf
  • 3,747
  • 6
  • 22
  • 38
  • I'm facing the exact same problem... well said! – stratis Jun 15 '19 at 21:23
  • 2
    The solution with refs is cumbersome, but right now it is the only one! However, [I have proposed a change here](https://github.com/reactjs/rfcs/issues/176) to distinguish between "whatDeps" and "whenDeps", which would exactly address your requirement. Feel free to comment / upvote the post to let React maintainers know there is an interest for a cleaner solution. – Jules Sam. Randolph Sep 11 '20 at 13:49

8 Answers8

36

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]);
David Táboas
  • 800
  • 6
  • 8
18

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;
  }, [value]);
  return ref.current;
}

and then use it in useEffect

function Component(props) {
  const hasItemIdChanged = useCompare(props.itemId);
  useEffect(() => {
    console.log('item id changed');
  }, [hasItemIdChanged])
  return <></>
}
Gunar Gessner
  • 2,331
  • 23
  • 23
  • simple & clean. Nice solution. – Gaurav Govilkar Oct 13 '20 at 11:26
  • 1
    I like the addition of `useCompare` +1 – pooley1994 Aug 13 '21 at 16:31
  • 1
    I used this solution for a case in which I wanted to run some code when a value was changed but not when it was initialized; however, I had to make an important change and I think it's due to a flaw in the implementation: if the value keeps changing, the boolean `hasItemChanged` will stay true and no effect will run. If you need to run the effect on every change of `props.itemId` but only when the value is different, you need to add `props.itemId` to the dependencies array, like this: `[hasItemIdChanged, props.itemId]`. Hope this helps – Giorgio Tempesta Oct 19 '21 at 08:51
  • 3
    @GiorgioTempesta You're absolutely right. I've run some test and have confirmed your fix. I've now fixed the code in my answer accordingly. Thank you. – Gunar Gessner Oct 20 '21 at 12:11
  • Isn't putting the props.itemId that we are comparing in the useCompare AND the dependency array simply equivalent to putting it in the dependency array? It will change when itemId changes, no matter the result of useCompare. – JM Lord Sep 19 '22 at 18:53
  • @JMLord Ah, much better! I've moved the `props.itemId` dependency into `usePrevious()` and removed the conditional inside the main `useEffect`—thanks! – Gunar Gessner Sep 20 '22 at 19:50
9

⚠️ 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 ]) 
Brad Adams
  • 2,066
  • 4
  • 29
  • 38
  • nice solution without having to use refs – devonj Mar 07 '20 at 00:42
  • I'm having trouble wrapping my head around when `useCallback` should be used over a normal callback. Would `items` be memoized on all changes or only at the expression and when the `useEffect` is fired? – devonj Mar 07 '20 at 00:54
  • 5
    Sorry buddy, this isn't right. `onItemIdChange` (the function) needs to be declared as a dependency to the `useEffect`. That would make this code ineffective as it regards OP's problem. – Gunar Gessner May 22 '20 at 08:07
  • @GunarGessner, not sure if it matters... `onItemIdChange` is a `const` so never changes once initialized. @Brad should pass it in in the second argument. But I think since it is initialized first, it is already available to the `useEffect` and will therefore never trigger it. – furnaceX May 23 '20 at 01:34
  • @furnaceX One would think, right? Even though `onItemIdChange` is a `const`, it is defined as the returned value of `useCallback()`. Which means it deals with React's internal rendering mechanism. Here's a good source for more information: https://overreacted.io/a-complete-guide-to-useeffect/ – Gunar Gessner May 26 '20 at 09:32
  • 1
    Yes, @GunarGessner is correct, I "discovered" this probably not long after posting this answer (was pretty new to hooks at the time), the method created with `useCallback` _should_ indeed be defined as a dependency of the `useEffect` hook. I'll update my answer – Brad Adams May 26 '20 at 20:30
  • @devonj to _try_ and answer your question (if I've understood correctly), `useCallback` is basically a layer over `useMemo` from what I've read - creating a memoized function, it's _regenerated_ when any of its dependencies change. This ties in with the issue @GunarGessner has raised, this callback should be in the dependencies of `useEffect`, which brings us back to square 1. As when the `items` dependency updates it will chain down to triggering the `useEffect`'s function. – Brad Adams May 26 '20 at 20:36
  • 1
    @BradAdams it would be nice if you removed this answer as it could lead people into bad habits! Or at least edit with a notice that this is not an acceptable answer. – Jules Sam. Randolph Sep 11 '20 at 20:49
  • Thanks for the reminder @JulesSam.Randolph, I've not had time to address it yet but have added a notice. – Brad Adams Sep 14 '20 at 20:12
7

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
John Leonard
  • 909
  • 11
  • 18
  • This is by far the best answer. Simple and concise. If I could upvote this 100 times, I would. Thanks! – Reid Dec 23 '20 at 15:58
  • Glad it helped! – John Leonard Dec 24 '20 at 17:45
  • love this, but i paired it with lodash.isEqual to compare object or arrays for changes, works perfectly – James Tan May 08 '21 at 04:36
  • I like the answer, but it violates this rule: "React Hook useEffect received a function whose dependencies are unknown. Pass an inline function instead.eslintreact-hooks/exhaustive-deps". I am yet to find another solution that does violate one of these rules though. – Gyum Fox Dec 03 '21 at 13:43
6

2022 answer

I know that this is an old question, but it's still worth an answer. Effects have received a lot of attention this year, and it's now clearer what you should / shouldn't do with effects than it was back then. The new React docs, which are still in beta, cover the topic in length.

So, before answering the 'how', you must first answer the 'why', because there aren't actually many genuine use cases for running an effect only when certain dependencies change.

You might not need an effect

'You might not need an effect' is actually the title of the page that demonstrates that you might be using an effect for the wrong reasons. And the example you gave us is actually discussed in 'adjusting some state when a prop changes'

Your code can be simply rewritten without the second effect:

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();
  }, [handleFetchItems]);

  // Find the item that matches in the list
  const item = items.find(item => item.id === props.itemId);
  
  return (
    <Button onClick={handleFetchItems}>Fetch items</Button>
  );
}

Indeed, you don't need an effect just to identify the item from the list. React already re-renders when items changes, so item can be re-computed in the component directly.

Note: if you really want to log to the console when the item changes, you can still add this code to your component:

  useEffect(() => {
    console.log("Item changed to ", item?.name);
  }, [item])

Note how item is the only dependency of the effect, so it only runs when item changes. But this is not a good use case for an effect again.

Instead, if you want to do something in response to an event that occurs in your application, you should do this

Separating Events from Effects

And yes, 'Separating Events from Effects' is also the title of a page in the new React docs! In the example you gave us, you want to write to the console whenever item changes. But I'm not naive and I know you might want to do more than this in a practice.

If you want to do something when something else changes, then you can use the new useEvent hook to create an event handler for it (or at least, use its polyfill since it hasn't been released yet). The event in question here is the itemId prop changing.

You can then change your second effect to:

// Handle a change of item ID
const onItemIdChange = useEvent((itemId) => {
  const item = items?.find(item => item.id === itemId);
  console.log("Item changed to " item.name);
});

// Trigger onItemIdChange when item ID changes
useEffect(() => {
  onItemIdChange(props.itemId);
}, [props.itemId]);

The advantage of this method is that useEvent returns a stable function, so you don't need to add it as a dependency to your effect (it never changes). Also, the function you pass to useEvent has no dependency array: it can access all the properties, states or variables inside your component and it is always fresh (no stale values).

I still think that based on the example you gave us, you don't really need an effect. But for other genuine cases, at least you know how to extract events from your effects now and thus you can avoid hacking the dependency array.

Gyum Fox
  • 3,287
  • 2
  • 41
  • 71
3

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'
Gyum Fox
  • 3,287
  • 2
  • 41
  • 71
0

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) ])
jokka
  • 1,832
  • 14
  • 12
0

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>
ApplePearPerson
  • 4,209
  • 4
  • 21
  • 36
  • 3
    I get a warning about a missing dependency with this: React Hook React.useEffect has a missing dependency: 'items.length'. Either include it or remove the dependency array react-hooks/exhaustive-deps – John Leonard Sep 26 '19 at 19:00