Strange behaviour in React component that includes message listener

710 Views Asked by At

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?

2

There are 2 best solutions below

1
On BEST ANSWER

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.

1
On

Try to put the message listener before sending event

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(() => {
      //first
      chrome.runtime.onMessage.addListener(message => increment())
      //then
        chrome.runtime.sendMessage({action: 'INCREMENT' })
        
    }, [ ])

    return <h1>Counter: {counter}</h1>
}

createRoot(document.getElementById('root')).render()