分かれば大したことない話なのですが、少しハマったので書き残しておきます。
事例
「Webページがクリックされる都度、クリック回数をカウントしてサイドパネルに表示する」
といったChrome拡張機能の開発を例として記載します。
拡張機能の内部構造としては、サイドパネルの部分をReact(Vite) + TypeScriptで作成して、
- Webページ側(Content Script)でクリックを検知
→サイドパネルにメッセージを投げる - サイドパネルではメッセージを受け取ったら、Reactのstateを用いてクリック数をカウント
という形とします。
- サービスワーカー(background.js)は、サイドパネルのオープン処理を実装
chrome.action.onClicked.addListener(tab=>{
chrome.sidePanel.open({windowId:tab.windowId});
});
- Content Scriptは、クリックを検知してメッセージを投げる処理を実装
document.addEventListener("click",_e=>{
chrome.runtime.sendMessage({type:"onClick"});
});
- サイドパネル(React製)は、クリック数のカウント管理と表示を実装
- Content Scriptからのメッセージ受信処理は、以下の記事を参考にして作成
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を作成
{
"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回目のクリックはカウンターが増えるものの、その後は増えてくれません。
原因
以下のコメントのように、 listener関数を addListener で登録したとき、 counter = 0(初期値) の動作で固定化されるのが原因です。
Reactだとイベントリスナーを登録することがあまり無いので、なるほどこういう挙動をするのだなと。
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関数にコールバック関数を渡すようにします。
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回クリックするたびにメッセージを表示」みたいに改修するならば以下のようになります。
追記
コメントでご指摘いただきましたが、以下の書き方は非推奨です。
推奨の書き方は記事の最後に追記しています。
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
document.addEventListener("click",_e=>{
chrome.runtime.sendMessage({type:"onClick"},(res)=>{
// 応答
if(res.counter % 3 === 0){
alert("3の倍数")
}
});
});
ちなみにここにも軽くハマりどころがあり、sendResponseがコールバックの中=非同期的に呼ばれることになるため、listener関数でtrueを返すようにします。詳細は公式ドキュメントを参照。
対処後
無事反映されるようになりました。
(追記) 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回クリックするたびにメッセージを表示」する処理を改めて実装すると以下のようになります。
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;
// こっちはそのまんまでOK
document.addEventListener("click",_e=>{
chrome.runtime.sendMessage({type:"onClick"},(res)=>{
// 応答
if(res.counter % 3 === 0){
alert("3の倍数")
}
});
});
1つフックを噛ます必要はあるものの、 setCounter の中であれこれ行う必要がなくなり、コードとしての見通しも良くなっているように感じます。

