I'm working on a component for a chrome extension that needs to listen for messages passed from other parts of the extension and update the display based on those messages.
A simplified version of what I thought should work is below but it does not do what I would expect.
This snippet successfully receives the message and calls the increment function, but never increments passed 1, with the log just repeating Incrementing 0 -> 1
// app.jsx
import React from 'react'
import { createRoot } from "react-dom/client";
function App() {
const [ counter, setCounter ] = React.useState(0)
function increment() {
console.log("Incrementing ", counter, " -> ", (counter + 1));
setCounter(counter + 1)
}
React.useEffect(() => {
chrome.runtime.onMessage.addListener(message => increment())
}, [ ])
return <h1>Counter: {counter}</h1>
}
createRoot(document.getElementById('root')).render(<App />)
After some experimentation I discovered that if I set an intermediary value, and then update the counter via a seperate effect, as in the snippet below, it works exactly as I would expect.
// app.jsx
import React from 'react'
import { createRoot } from "react-dom/client";
function App() {
const [ counter, setCounter ] = React.useState(0)
const [ message, setMessage ] = React.useState()
function increment() {
console.log("Incrementing ", counter, " -> ", (counter + 1));
setCounter(counter + 1)
}
React.useEffect(() => {
chrome.runtime.onMessage.addListener(message => setMessage(message))
}, [ ])
React.useEffect(() => increment(), [ message ])
return <h1>Counter: {counter}</h1>
}
createRoot(document.getElementById('root')).render(<App />)
The message sending is identical in both versions and working as expected in both cases. It is just a sendMessage call from a content script.
// contentScript.js
chrome.runtime.sendMessage({action: 'INCREMENT' })
This solution is acceptable, but I'm having trouble understanding why the second works as expected when the first does not.
Is anyone able to explain this behaviour?
It is a stale closure problem:
function increment() {
console.log("Incrementing ", counter, " -> ", (counter + 1));
setCounter(counter + 1)
}
React.useEffect(() => {
chrome.runtime.onMessage.addListener(message => increment())
}, [ ])
increment
will always be from first render (hence also counter
), because the useEffect
was run only once due to empty array as dependency.
One way to change your code will be:
React.useEffect(() => {
// By moving increment inside useEffect it is not needed to put as dependency
function increment() {
// by using functional set state we always have access to recent state
setCounter(ps => ps + 1)
}
chrome.runtime.onMessage.addListener(message => increment())
return () => {
// You can handle remove listener here
}
}, [])
But here is more reading about this topic.