TIL Message handler inside of useEffect
![TIL Message handler inside of useEffect](/content/images/size/w2000/2025/02/DALL-E-2025-02-04-11.00.55---An-illustrative-16_9-image-representing-the-concept--TIL-Message-handler-inside-of-useEffect.--The-design-features-a-dynamic-and-abstract-depiction-of.jpeg)
useEffects have a lot of footguns, especially with the dependency list. This is one of them.
If I have a web worker that I need to communicate with, I need to have a message handler.
The message handler needs to change the state of my frontend application.
It's easy to forget that state in useEffect is frozen. So if anything else calls into that closure, it'll have a stale state."No problem," one thinks, "just add the state into the dependency list."
const Component = () => {
const workerRef = useRef<Worker | null>(null);
const [cells, setCells] = useState<Cell[]>([]);
useEffect(() => {
workerRef.current = new Worker(URL.createObjectURL(kernelBlob));
// post a message
}, [])
useEffect(() => {
if (workerRef.current) return;
workerRef.current.onmessage = (event) => {
let msg = event.data;
switch (msg.type) {
case MsgType.RunCellMsg: {
const updatedCells = [...cells];
updatedCells[msg.cellId] = {
...updatedCells[msg.cellId],
output: msg.output,
}
setCells(updatedCells)
break;
}
}
}
}, [cells])
}
Note:
- We call
setCells
on the computed updated cells. - the dependency list has
[cells]
The problem is two-fold:
- The setup for the message handler is run over and over again when it doesn't need to.
- If multiple messages come in before setState has a chance to run, then each setState would think that the previous state are the same. So then only one of the setStates will succeed. This is the equivalent of just dropping messages.
The solution is to use the callback to setState so that the most updated state is used.
const Component = () => {
const workerRef = useRef<Worker | null>(null);
const [cells, setCells] = useState<Cell[]>([]);
useEffect(() => {
workerRef.current = new Worker(URL.createObjectURL(kernelBlob));
// post a message
}, [])
useEffect(() => {
if (workerRef.current) return;
workerRef.current.onmessage = (event) => {
let msg = event.data;
switch (msg.type) {
case MsgType.RunCellMsg: {
setCells((cells) => {
const updatedCells = [...cells];
updatedCells[msg.cellId] = {
...updatedCells[msg.cellId],
output: msg.output,
}
}
break;
}
}
}
}, [])
}
Note:
- we use
setCells
with a callback to be passed an updated version of the state, rather than relying on the stale version in the closure. - there's no dependency on 'cells' in the useEffect dependency list.
This is a footgun since there are multiple ways to access "latest state", and initially, it's not clear how they're all different and in what context.
If React is the one controlling the entire lifecycle of changing the state, you can use the dependency list.
But if something else is calling into a callback within useEffect, such as a message handler, then you'll need to use the setState callback to fetch the latest state.
This is bad API design. There should only be one way to fetch the latest previous state and the user shouldn't have to hint at what the dependencies are.