I have a functional component using Hooks:
function Component(props) {
const [ items, setItems ] = useState([]);
// In a callback Hook to prevent unnece
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) ])
An easy way out is to write a custom hook to help us with that
// Desired hook
const useCompare = (val) => {
const prevVal = usePrevious(val)
return prevVal !== val
}
// Helper hook
const usePrevious = (value) => {
const ref = useRef();
useEffect(() => {
ref.current = value;
});
return ref.current;
}
and then use it in useEffect
const Component = (props) => {
const hasItemIdChanged = useCompare(props.itemId);
useEffect(() => {
if(hasItemIdChanged) {
// ...
}
}, [items, props.itemId])
}
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]);
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>
⚠️ NOTE: This answer is currently incorrect and could lead to unexpected bugs / side-effects. The
useCallback
variable would need to be a dependency of theuseEffect
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 ])
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