ReactでBroadcastChannelを使い、ほかのタブと状態をそろえる方法

​​React​BroadcastChannel,Playwright,TypeScript,useEffect,useEffectEvent,カスタムフック

同じサイトを複数のタブで開いているとき、片方のタブでカートに入れた点数を、ほかのタブの表示にもすぐ反映できます。ブラウザの BroadcastChannel を useEffect の中で作り、後片付けで閉じるカスタムフックにすると、コンポーネントからは useState とほとんど同じ形で使えます。

筆者は検証環境で、カートの点数を表示する小さな画面を作り、Chromium の複数のタブを Playwright で操作して、値がそろうかどうかと、チャンネルが作られた数・閉じられた数を確かめました。

補足

検証環境は Ubuntu 24.04、Node.js 22.22.0 です。使ったのは react / react-dom 19.3.1、@types/react 19.3.0、typescript 7.0.2、esbuild 0.28.2 です。.tsx を tsc で型チェックしてから esbuild で変換し、Chromium 153 を Playwright 1.63.0 で操作しました。画面は同じマシンの中の HTTP サーバー(http://127.0.0.1)から配っています。Firefox と Safari での動作、タブを別のウィンドウに分けた場合、iframe や Worker とのやりとりは試していません。

BroadcastChannel は、同じオリジンのタブどうしで送り合う仕組み

MDN の Broadcast Channel API のページによると、BroadcastChannel は、同じオリジンのタブ、ウィンドウ、iframe、Worker のあいだでメッセージを送り合うための仕組みです。同じ名前で new BroadcastChannel('cart') を作れば、相手を知らなくても同じチャンネルにつながります。postMessage() で送ると、同じチャンネルにつないでいるほかの BroadcastChannel に message イベントが届きます。

使う前に知っておきたいのは、次の3点です。

  • 送った BroadcastChannel 自身には届かない
  • 値をためておく場所はなく、あとからつないだものには、それまでのメッセージは届かない
  • 使い終わったら close() で閉じる

1つ目は検証環境でも確かめました。同じタブの中で同じ名前のチャンネルを2つ作り、片方から 'hello' を送った結果です。

text
送ったチャンネル: []、同じタブの別のチャンネル: ["hello"]

送ったチャンネルには届かず、同じタブにある別のチャンネルには届きました。MDN の BroadcastChannel のページにも、送ったオブジェクトを除く、すべての BroadcastChannel に届くとあります。タブ単位ではなく、オブジェクト単位で除かれます。

チャンネルをカスタムフックにまとめる

まず、チャンネルにつなぐ、受け取る、閉じるの3つだけを受け持つフックを作ります。

ts
import { useCallback, useEffect, useEffectEvent, useRef } from 'react';

// name のチャンネルにつなぎ、ほかのタブから届いたメッセージを onMessage に渡す。
// 返す関数で、同じチャンネルにつないでいるほかのタブへ送る
export function useBroadcastChannel<T>(name: string, onMessage: (data: T) => void) {
  const channelRef = useRef<BroadcastChannel | null>(null);

  // つなぎ直さずに、届いた時点の最新の onMessage を呼ぶ
  const handleMessage = useEffectEvent((event: MessageEvent<T>) => {
    onMessage(event.data);
  });

  useEffect(() => {
    const channel = new BroadcastChannel(name);
    channelRef.current = channel;
    channel.onmessage = (event: MessageEvent<T>) => handleMessage(event);
    return () => {
      channelRef.current = null;
      channel.close(); // 閉じないと、外したあとも受け取り続ける
    };
  }, [name]);

  return useCallback((data: T) => {
    channelRef.current?.postMessage(data);
  }, []);
}

受け取ったときの処理は、useEffectEvent で包み、useEffect の依存配列には入れていません。使う側が描画のたびに新しい関数を渡しても、チャンネルを作り直さずに済みます。公式ドキュメントの、カスタムフックにイベントハンドラを渡す例と同じ形です。

値をそろえるフックと、使う側のコード

このフックを使って、useState と同じように値と更新の関数を返す useSharedState を作ります。チャンネルの名前(name)は、途中で変えない前提です。送るメッセージは、値を知らせる set と、いまの値を聞く request の2種類にしました。request と2つの ref を使う理由は、あとの節で説明します。

ts
import { useCallback, useEffect, useRef, useState } from 'react';
import { useBroadcastChannel } from './useBroadcastChannel';

type Message<T> = { type: 'set'; value: T } | { type: 'request' };

// useState と同じように使えて、同じ name を使うほかのタブと値をそろえる
export function useSharedState<T>(name: string, initialValue: T) {
  const [value, setValue] = useState(initialValue);
  const valueRef = useRef(initialValue); // 描画を待たずに読める、いまの値
  const knownRef = useRef(false); // ほかのタブから値を受け取ったか、自分で値を変えたか

  const post = useBroadcastChannel<Message<T>>(name, (message) => {
    if (message.type === 'set') {
      valueRef.current = message.value;
      knownRef.current = true;
      setValue(message.value);
    } else if (message.type === 'request' && knownRef.current) {
      post({ type: 'set', value: valueRef.current }); // あとから開いたタブに、いまの値を教える
    }
  });

  // 開いたときに、すでに開いているタブへいまの値を聞く
  useEffect(() => {
    post({ type: 'request' });
  }, [post]);

  const setShared = useCallback(
    (next: T) => {
      valueRef.current = next;
      knownRef.current = true;
      setValue(next);
      post({ type: 'set', value: next });
    },
    [post],
  );

  return [value, setShared] as const;
}

自分のタブの表示は setValue で変え、ほかのタブには post で知らせます。送ったチャンネル自身には届かないので、自分が送った set を受け取って二重に処理することはありません。

使う側は、useState を useSharedState に置き換えて、チャンネルの名前を渡すだけです。

tsx
import { useSharedState } from './useSharedState';

export function CartDemo() {
  const [count, setCount] = useSharedState('cart', 0);
  return (
    <section>
      <p>
        カート: <output id="count">{count}</output> 点
      </p>
      <button onClick={() => setCount(count + 1)}>カートに入れる</button>
      <button onClick={() => setCount(0)}>空にする</button>
    </section>
  );
}

同じブラウザでこの画面を2つのタブ(A と B)に開き、A で「カートに入れる」を2回、B で「空にする」を押した結果です。

text
タブ A で2回押したあと: A=2、B=2
タブ B で空にしたあと: A=0、B=0

どちらのタブで押しても、もう片方の表示がそろいました。次の GIF は、同じ画面の2つのタブを縦に並べて撮ったものです。上の帯の操作の表示、カーソル、押した瞬間の輪、変わった数字のピンクの強調は、動きが分かるように撮影用に足したもので、CartDemo のコードには入っていません。

上下に「タブ A」と「タブ B」が並ぶ。上の帯に、何回目の操作でどのタブのどのボタンを押したかが出て、押した瞬間に両方のタブのカートの点数がピンクで光って同じ数に変わる。最後にタブ B で「空にする」を押すと、両方が 0 になる
タブ A で2回、タブ B で1回カートに入れてから、タブ B で空にしたときの、2つのタブの点数の動き

あとから開いたタブには、いまの値を聞いて渡す

BroadcastChannel は、送ったメッセージをためておきません。そのため、値を送り合うだけの書き方では、あとから開いたタブは初期値のままになります。比べるために、開いたときに値を聞かず、値をそのまま送り合うだけの書き方も作りました。

ts
channel.onmessage = (event: MessageEvent<T>) => setValue(event.data);

タブ A で3回押してから、タブ B を開いた結果です。1行目が値を送り合うだけの書き方、2行目が記事の useSharedState です。

text
no-request: タブ A で3回押してから開いたタブ B: A=3、B=0
cart: タブ A で3回押してから開いたタブ B: A=3、B=3

送り合うだけの書き方では、B は 0 のままでした。B を開く前に送られた3回分のメッセージは、B には届きません。記事のフックでは、B が開いたときに request を送り、それを受け取った A が set でいまの値を返すので、B も 3 になりました。

ここで気をつけたいのは、set の返事も、聞いてきたタブだけでなく、同じチャンネルのすべてのタブに届くことです。タブが同時に開くと、これが値のずれを生むことがあります。タブ A が3点を持っているときに、B と C がほぼ同時に開く場面を考えます。B は C の request にも答えますが、そのとき B がまだ A の返事を受け取っていなければ、B は初期値の 0 を全員に送ります。A の 3 が 0 で上書きされます。

この上書きを防ぐのが、knownRef と valueRef です。knownRef は、ほかのタブから値を受け取ったか、自分で値を変えたかを覚えておき、まだ値を知らないタブは request に答えません。valueRef は、setValue の結果が描画されるのを待たずに、いまの値を読むために使います。useEffectEvent のリファレンスによると、包んだ処理が読むのは、呼ばれた時点で最後に描画された props と state です。そのため state の value で答えると、set を受け取った直後、描画が終わる前に届いた request には、1つ前の値で答えてしまいます。

タブ A で3回押してから、B と C をできるだけ同時に開く操作を、10回ずつ繰り返しました。1行目は2つの ref を使わない書き方、2行目は knownRef だけを足した書き方、3行目が記事の useSharedState です。

text
no-guard: 10回のうち3つのタブがそろったのは 7 回、結果(A/B/C) 0/0/3 3/3/3 3/3/3 3/3/3 0/0/3 3/3/3 0/3/0 3/3/3 3/3/3 3/3/3
known-only: 10回のうち3つのタブがそろったのは 8 回、結果(A/B/C) 3/3/3 3/3/3 3/3/3 3/3/3 0/3/0 3/3/3 3/3/3 0/0/3 3/3/3 3/3/3
cart: 10回のうち3つのタブがそろったのは 10 回、結果(A/B/C) 3/3/3 3/3/3 3/3/3 3/3/3 3/3/3 3/3/3 3/3/3 3/3/3 3/3/3 3/3/3

2つの ref を使わない書き方では、10回のうち3回、どれかのタブが 0 に戻りました。knownRef だけでも 2回失敗しました。state の value で答えているため、set を受け取った直後の request に、まだ古い値で答えてしまうからです。両方を使う記事のフックは、10回とも3つのタブがそろいました。どの回で失敗するかは実行するたびに変わるので、失敗した回数そのものは、この実行での値です。

すべてのタブを閉じたあとも値を残したい場合は、BroadcastChannel だけでは足りず、localStorage などに保存する必要があります。

後片付けで close() を呼ばないと、外したあとも受け取り続ける

MDN の close() のページによると、close() を呼ぶほかに、チャンネルがもう要らないことをブラウザが知る方法はありません。比べるために、後片付けで close() を呼ばない書き方も作りました。違うのは、後片付けの中身だけです。

ts
return () => {
  channelRef.current = null;
};

受け取るだけのコンポーネントを用意し、「表示の切り替え」ボタンで、外す・付けるを2回ずつ繰り返しました。そのあと、別のタブから1回だけ送った結果です。

text
listener: 1回送ったときに受け取りの処理が呼ばれた回数 1、表示「最後に届いたもの: お知らせ」、チャンネル {"created":3,"closed":2,"delivered":1}
listener-no-close: 1回送ったときに受け取りの処理が呼ばれた回数 3、表示「最後に届いたもの: お知らせ」、チャンネル {"created":3,"closed":0,"delivered":3}

created はページで作られたチャンネルの数、closed は閉じられた数、delivered はチャンネルに届いたメッセージの数です。付けるたびにチャンネルが作られるので、どちらも3つ作られています。記事のフック(1行目)は、外したときに2つ閉じ、1回の送信で受け取りの処理が1回だけ呼ばれました。close() を呼ばない書き方(2行目)は、外したコンポーネントのチャンネルも開いたままで、1回の送信で受け取りの処理が3回呼ばれました。画面の表示は同じでも、外したコンポーネントの処理が裏で動き続けます。付けるたびに、開いたままのチャンネルが1つずつ増えていきます。

届くのは、同じブラウザの同じオリジンのタブだけ

タブ A で1回押したときに、ほかのタブの表示がどうなったかを確かめました。比べたのは、同じオリジンのタブ、ポートを変えて別のオリジンにしたタブ、そして Playwright で別のブラウザコンテキストを作って同じ URL を開いたタブの3つです。Playwright のドキュメントにある、テストを独立させる仕組みの説明によると、ブラウザコンテキストはシークレットウィンドウに近いプロフィールにあたり、localStorage、sessionStorage、Cookie などをそれぞれ別に持ちます。

text
タブ A (http://127.0.0.1:5173) で1回押したあと: A=1
  同じオリジンのタブ: 1
  ポートが違うタブ (http://127.0.0.1:5174): 0
  別のブラウザコンテキストの同じ URL のタブ: 0

届いたのは、同じオリジンで同じブラウザコンテキストのタブだけでした。先ほどの MDN の Broadcast Channel API のページには、正確には、同じストレージパーティション(保存したデータの区切り)を使っているブラウジングコンテキストのあいだで送り合える、とあります。そのため、同じオリジンでも、別のサイトに埋め込まれた iframe とは送り合えない場合があります。ブラウザの中だけで送り合う仕組みなので、別の端末の画面をそろえたいときは、サーバーをはさむ WebSocket などが必要になります。

送れるのは、構造化複製できる値だけ

MDN の postMessage() のページによると、送る値は構造化複製(structured clone)の仕組みでコピーされます。Date や Map を入れたオブジェクトを送り、受け取った側で型を確かめました。続けて、関数を含むオブジェクトを送った場合と、close() のあとに送った場合も試しました。

text
届いたもの: [{"dateIsDate":true,"mapIsMap":true,"mapSize":1,"nested":[{"id":1}]}]
関数を含むオブジェクト: DataCloneError: Failed to execute 'postMessage' on 'BroadcastChannel': () => {} could not be cloned.
close() のあとの postMessage: InvalidStateError: Failed to execute 'postMessage' on 'BroadcastChannel': Channel is closed

Date と Map は、受け取った側でも Date と Map のままでした。関数を含むオブジェクトは DataCloneError に、閉じたあとの送信は InvalidStateError になりました。どちらも、送る側で例外が投げられます。state にイベントハンドラなどの関数を入れている場合は、送る前に値だけを取り出します。記事の useBroadcastChannel は、後片付けで channelRef.current を null にしているので、外したあとに送ろうとしても、閉じたチャンネルの postMessage() は呼ばれません。

開発中の StrictMode では、チャンネルが2回作られる

開発用のビルドで、StrictMode のあり・なしを比べました。タブ B を開いた直後と、タブ A で1回押したあとの、タブ B のチャンネルの数と、届いたメッセージの数です。

text
StrictMode なし: タブ B のチャンネル(開いた直後){"created":1,"closed":0,"delivered":0}、タブ A で1回押したあと {"created":1,"closed":0,"delivered":1}、B=1
StrictMode あり: タブ B のチャンネル(開いた直後){"created":2,"closed":1,"delivered":0}、タブ A で1回押したあと {"created":2,"closed":1,"delivered":1}、B=1

StrictMode ありでは、チャンネルが2つ作られ、1つは後片付けで閉じられました。最後まで開いていたのは1つで、A で押したあとの B の点数も 1 でした。開いたときの request も2回送られますが、答えるタブがなければ何も起きません。公式ドキュメントの、開発中にエフェクトが2回動くときの説明によると、これは開発中だけの、意図した動きです。後片付けで閉じていれば、開いているチャンネルは1つになります。

タブ間の同期は、閉じることと、あとから来たタブへの返事で決まる

ポイント

BroadcastChannel は useEffect の中で作り、後片付けで close() します。受け取ったときの処理は useEffectEvent で包みます。メッセージはためておかれないので、あとから開いたタブは開いたときにいまの値を聞き、値を知っているタブが、描画を待たずに読める ref の値で答えるようにします。送れるのは構造化複製できる値だけで、届くのは同じブラウザの同じオリジンのタブだけです。

​Posted by 86