2
2

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

【Chrome拡張】【React】addListenerに登録した関数内でstateが更新されないときの対処法メモ

2
Last updated at Posted at 2026-06-14

分かれば大したことない話なのですが、少しハマったので書き残しておきます。

事例

「Webページがクリックされる都度、クリック回数をカウントしてサイドパネルに表示する」
といったChrome拡張機能の開発を例として記載します。

拡張機能の内部構造としては、サイドパネルの部分をReact(Vite) + TypeScriptで作成して、

  • Webページ側(Content Script)でクリックを検知
    →サイドパネルにメッセージを投げる
  • サイドパネルではメッセージを受け取ったら、Reactのstateを用いてクリック数をカウント

という形とします。


  • サービスワーカー(background.js)は、サイドパネルのオープン処理を実装
background.js
chrome.action.onClicked.addListener(tab=>{
  chrome.sidePanel.open({windowId:tab.windowId});
});
  • Content Scriptは、クリックを検知してメッセージを投げる処理を実装
content-script.js
document.addEventListener("click",_e=>{
  chrome.runtime.sendMessage({type:"onClick"});
});
  • サイドパネル(React製)は、クリック数のカウント管理と表示を実装
    • Content Scriptからのメッセージ受信処理は、以下の記事を参考にして作成

App.tsx
import { useState,useEffect } from 'react'
import './App.css'

function App(){
  // クリック数のカウンターをstateとして持つ
  const [counter,setCounter] = useState<number>(0);

  useEffect(()=>{
    const listener = (message: any) =>{
      if (message.type === 'onClick') {
        // onClickのメッセージが来たらstateのカウンターを増やす
        setCounter(counter+1);
      }
    };
    
    chrome.runtime.onMessage.addListener(listener);
    return () => chrome.runtime.onMessage.removeListener(listener);
  }, []);

  return(
    <div>クリック数:{counter}</div>
  )
}
export default App

  • Reactはビルドしてhtmlにしたうえで、以下のようにmanifest.jsonを作成
manifest.json
{
  "name": "test拡張機能",
  "description": "テストじゃ",
  "version": "1.0",
  "manifest_version": 3,
  "side_panel":{
    "default_path":"index.html"
  },
  "background":{
    "service_worker":"background.js"
  },
  "content_scripts":[
    {
      "js":["content_script.js"],
      "matches":["<all_urls>"]
    }
  ],
  "action":{},
  "permissions":["sidePanel"]
}

こんな感じで作った拡張機能をブラウザで読み込んで実行すると…
以下のように、1回目のクリックはカウンターが増えるものの、その後は増えてくれません。

失敗.gif

原因

以下のコメントのように、 listener関数を addListener で登録したとき、 counter = 0(初期値) の動作で固定化されるのが原因です。
Reactだとイベントリスナーを登録することがあまり無いので、なるほどこういう挙動をするのだなと。

App.tsx(一部抜粋)
  useEffect(()=>{
    const listener = (message: any) =>{
      if (message.type === 'onClick') {
        // listener関数の宣言時、counterは当然0
        setCounter(counter+1); // ★
      }
    };
    // その流れでaddListenerに登録する処理が呼ばれる
    chrome.runtime.onMessage.addListener(listener);
    // →counterが初期値(=0)の状態で作成されたlistenerが登録される
    //  よって、上記★のsetCounterが、何度呼んでも1をセットする動作になってしまう

    return () => chrome.runtime.onMessage.removeListener(listener);
  }, []);

対処

listener関数内で、state値を参照しないようにするのが一番スマートだと思います。
そのために、stateのset関数にコールバック関数を渡すようにします。

App.tsx(一部抜粋)
  useEffect(()=>{
    const listener = (message: any) =>{
      if (message.type === 'onClick') {
        // onClickのメッセージが来たらstateのカウンターを増やす
-       setCounter(counter+1);
+       setCounter(prev=>prev+1);
      }
    };
    chrome.runtime.onMessage.addListener(listener);
    return () => chrome.runtime.onMessage.removeListener(listener);
  }, []);

恥ずかしながら、stateのset関数におけるこの書き方を知らなかったのですが、
こう書くと、set関数が実行されたタイミングで、引数( prev )に「現在のstate値」がセットされた状態でコールバック関数が呼び出されると。
あとは更新したい値をreturnすれば、その値でstate値が更新されるようです。


よって、Content Scriptへの応答として更新後のstate値を返したいときも、このコールバック関数の中でやるのがよさそうです。
例えば、「3回クリックするたびにメッセージを表示」みたいに改修するならば以下のようになります。

追記
コメントでご指摘いただきましたが、以下の書き方は非推奨です。
推奨の書き方は記事の最後に追記しています。

App.tsx
import { useState,useEffect } from 'react'
import './App.css'

function App(){
  // クリック数のカウンターをstateとして持つ
  const [counter,setCounter] = useState<number>(0);

  useEffect(()=>{
    const listener = (message: any,_:any,sendResponse:any) =>{
      if (message.type === 'onClick') {
        // onClickのメッセージが来たらstateのカウンターを増やす
        setCounter(prev=>{
          // stateのカウンターを増やす
          const new_value = prev + 1;

          // Content Scriptへの応答
          sendResponse({counter:new_value});

          // stateへのセットのため値を返す
          return new_value;
        });
      }
      // sendResponseが非同期で呼ばれるのでtrueを返す必要あり
      return true;
    };
    
    chrome.runtime.onMessage.addListener(listener);
    return () => chrome.runtime.onMessage.removeListener(listener);
  }, []);

  return(
    <div>クリック数:{counter}</div>
  )
}
export default App

content-script.js
document.addEventListener("click",_e=>{
  chrome.runtime.sendMessage({type:"onClick"},(res)=>{
    // 応答
    if(res.counter % 3 === 0){
      alert("3の倍数")
    }
  });
});

ちなみにここにも軽くハマりどころがあり、sendResponseがコールバックの中=非同期的に呼ばれることになるため、listener関数でtrueを返すようにします。詳細は公式ドキュメントを参照。

対処後

無事反映されるようになりました。

成功.gif

(追記) Content Scriptへ応答したかったら useEffectEvent を使おう

@honey32 さんよりコメントいただきましたが、以下のようなset関数に渡すコールバック関数の内部で sendResponse するのは非推奨の書き方です。

        setCounter(prev=>{
          // stateのカウンターを増やす
          const new_value = prev + 1;

          // Content Scriptへの応答 ← これがあかん
          sendResponse({counter:new_value});

          // stateへのセットのため値を返す
          return new_value;
        });

React は、あなたの書くすべてのコンポーネントが純関数 (pure function) であると仮定しています。これは、あなたが書く React コンポーネントは、同じ入力(props、state、context)が与えられた場合に常に同じ JSX を返さなければならないという意味です。

このルールを守らないコンポーネントは予測不能な挙動を示し、バグを引き起こします。うっかり純粋でなくなってしまったコードを見つけるために、Strict Mode はあなたの関数の一部(純粋であるべきものだけ)を開発中に 2 回呼び出します。これには以下が含まれます。

  • あなたのコンポーネント関数本体(トップレベルのロジックのみ。イベントハンドラ内のコードは含まれません。)
  • useState、set 関数、useMemo、および useReducer に渡す関数

言われてみると、set関数…つまりはstateに値をセットするための関数内で、それ以外のこと(Content Scriptへの応答)をやるのは、コードとしても美しくはないですね。

stateを更新した後の値でContent Scriptへ応答したい場合、 useEffectEvent を用います。

エフェクトイベントはエフェクトのロジックの一部ですが、よりイベントハンドラに近いふるまいをします。常に最新のレンダー時の値(props や state など)が “見える” 一方で、エフェクトの再同期を起こさないため、エフェクトの依存配列には入れません。

ということで、 useEffectEvent 内では最新のstate値を参照することが可能となります。

タイマーで最新の値を使う の所で書かれているソースが参考になりそうなので、これを元に「3回クリックするたびにメッセージを表示」する処理を改めて実装すると以下のようになります。

App.tsx
import { useState,useEffect,useEffectEvent } from 'react'
import './App.css'

function App(){
  // クリック数のカウンターをstateとして持つ
  const [counter,setCounter] = useState<number>(0);

   // useEffectiveEventでクリック時のイベントを実装
  const onClick = useEffectEvent((sendResponse:any)=>{
    // この中だとstateのcounter値が常に最新のものになるので、こう書いてOK
    const next_counter = counter+1;
    setCounter(next_counter);
    sendResponse({counter:next_counter});
  });

  useEffect(()=>{
    const listener = (message: any,_:any,sendResponse:any) =>{
      if (message.type === 'onClick') {
        // ↑のuseEffectiveEventで作ったイベントを呼び出す
        onClick(sendResponse)
      }
      // sendResponseが非同期で呼ばれるのでtrueを返す必要あり
      return true;
    };
    
    chrome.runtime.onMessage.addListener(listener);
    return () => chrome.runtime.onMessage.removeListener(listener);
  }, []);

  return(
    <div>クリック数:{counter}</div>
  )
}
export default App;

content-script.js
// こっちはそのまんまでOK
document.addEventListener("click",_e=>{
  chrome.runtime.sendMessage({type:"onClick"},(res)=>{
    // 応答
    if(res.counter % 3 === 0){
      alert("3の倍数")
    }
  });
});

1つフックを噛ます必要はあるものの、 setCounter の中であれこれ行う必要がなくなり、コードとしての見通しも良くなっているように感じます。

2
2
3

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
2
2

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?