A friend of mine had this problem.
Basically, every time my list changes, my websocket has to reconnect.
He had a call to useEffect
that was setting up a websocket connection, and tracking some items in a list, something like this snippet:
const [list, setList] = useState([]);
const showLoading = useCallback((id, loading) => {
// Update the list
setList(list.map(item => {
if(item.id === id) {
return {
...item,
isLoading: loading
}
}
return item;
}));
}, [list]);
useEffect(() => {
// Connect the websocket
const ws = new Websocket(...);
// A message signals to reload one of the `list` items
ws.addEventListener("message", e => {
showLoading(e.id, true);
});
// Before next effect runs, close this websocket
return () => ws.close();
}, [showLoading]);
Now, this isn’t a complete component; in the full code there’s probably something that loads data for the list
items, and surely some content to be returned. That would only distract from the bug, though.
The problem is between the effect and the callback, and specifically, it’s a problem with their dependencies.
The chain of events goes like this:
- The component mounts, runs the effect, and connects the websocket. Great.
- Later, a message comes in. The handler calls
showLoading
. Fine. showLoading
calls setList, which modifies thelist
. Okay.- Now, anything that depends on
list
must be recomputed… (uh oh)- the useCallback function,
showLoading
, depends on[list]
. So it gets recreated. - the useEffect depends on
[showLoading]
… so now that gets recreated too.
- the useCallback function,
So it ends up that every message triggers the effect to re-run, which disconnects and reconnects the websocket.
console.log to the rescue
If you had code like this, inserting console.log
s in key places would help with understanding the timing. I’d probably sprinkle them around like this:
const [list, setList] = useState([]);
console.log('[render]');
const showLoading = useCallback((id, loading) => {
console.log('[showLoading]', id, loading);
// Update the list
setList(list.map(item => {
if(item.id === id) {
return {
...item,
isLoading: loading
}
}
return item;
}));
}, [list]);
useEffect(() => {
console.log('[useEffect] setup');
// Connect the websocket
const ws = new Websocket(...);
// A message signals to reload one of the `list` items
ws.addEventListener("message", e => {
console.log('[useEffect] websocket message!');
showLoading(e.id, true);
});
// Before next effect runs, close this websocket
return () => {
ws.close();
console.log('[useEffect] cleanup');
}
}, [showLoading]);
I’d expect to see it print something like this:
[render]
[useEffect] setup
[useEffect] websocket message!
[showLoading] 1 true
[render]
[useEffect] cleanup
[useEffect] setup
You might see some extra renders in dev mode. Sometimes React calls components twice in dev mode, but that shouldn’t happen in the production build.
Fix useEffect Running Too Often
We need to break the chain somehow. The effect depends on showLoading
, and showLoading
depends on the list
– ipso facto, the effect depends on the list
.
Ultimately, the effect needs to depend on less stuff.
Here is a perfect place for functional setState. Have a look:
const [list, setList] = useState([]);
// Normal setState:
setList(list.map(...))
// Functional setState:
setList(oldList => oldList.map(...))
The functional or “updater” form of the state setter will take a function, and pass the current state value to that function. Whatever the function returns becomes the new state value.
This is great because it means we don’t need to depend on list
to be able to update the list
!
Here’s our functional setState fix applied:
const [list, setList] = useState([]);
const showLoading = useCallback((id, loading) => {
// Update the list
setList(currentList => currentList.map(item => {
if(item.id === id) {
return {
...item,
isLoading: loading
}
}
return item;
}));
}, []); // <-- depends on nothing, now
useEffect(() => {
// Connect the websocket
const ws = new Websocket(...);
// A message signals to reload one of the `list` items
ws.addEventListener("message", e => {
showLoading(e.id, true);
});
// Before next effect runs, close this websocket
return () => ws.close();
}, [showLoading]); // <-- showLoading will never change
That change fixes the problem. Now, when websocket messages come in, showLoading
will be called, the list will be updated, and the effect won’t run again.
Move Callbacks Inside The useEffect
You might run into something like this, where the useEffect needs to call a function from the surrounding scope:
const doStuff = () => { ... }
useEffect(() => {
if(whatever) {
doStuff();
}
}, [whatever, doStuff]);
This isn’t great, because the effect will re-run on every render.
Remember that React calls your component function on every render, and const doStuff
here is just a local variable – a variable that will go out of scope when the function ends, and be recreated the next time around.
To make this effect run less often, move the callback inside the effect:
useEffect(() => {
const doStuff = () => { ... }
if(whatever) {
doStuff();
}
}, [whatever]);
Further Reading on useEffect
React’s useEffect hook can feel like a magical incantation sometimes. Mostly, it’s that dependency array.
With no array at all, your effect function will run every render.
With an empty array []
, the effect will run only once.
With variables in the array, like [a, b]
, the effect will run only when a
or b
change.
These variables can be state or one or more props or anything else you need.
Check out my post Examples of the useEffect Hook for more depth and more examples.