React useEffect Hook when only one of the effect's deps changes, but not the others
JavascriptReactjsReact HooksJavascript 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);
};
The signature of useGranularEffect
is the same as useEffect
, except that the list of dependencies has been split into two:
- primary dependencies: the effect only runs when these dependencies change
- 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
becauseeffect
is not an actual dependency (it's the effect function itself) andref.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>