概要
Zustand Test を 2 つのブラウザタブで開いた場合、片方で保存した値はもう片方には自動では反映されません。
これを解決するのが BroadcastChannel API です。同一オリジンのタブ間で軽量なメッセージング ができ、persist のように storage event を待つよりリアルタイム性が高いです。
| 用途 | persist (localStorage) | BroadcastChannel |
|---|---|---|
| 反映タイミング | 次のレンダリング・タブ復帰時 | 即時 |
| ペイロード | localStorage の容量・JSON 化が必須 | 任意のオブジェクトをそのまま渡せる |
| 用途 | リロード後の 復元 | 同時に開いているタブの 同期 |
両者は組み合わせて使えます。本記事は BroadcastChannel に絞って解説します。
インストール
BroadcastChannel は標準 API です。追加インストールは不要。
チャネルを 1 か所で作る
チャネルは モジュールスコープで 1 度だけ 生成し、複数の場所から再利用します。
// src/store/settingsBroadcast.ts
const CHANNEL_NAME = "playground.settings";
let channel: BroadcastChannel | null = null;
export function getSettingsChannel(): BroadcastChannel | null {
if (typeof window === "undefined") return null; // SSR 中はスキップ
if (!channel) channel = new BroadcastChannel(CHANNEL_NAME);
return channel;
}
SSR では BroadcastChannel が undefined です。typeof window === "undefined" のガードを必ず付けます。
送信側: store の変化をブロードキャスト
subscribeWithSelector で settingsStore の確定値(name, email, rate)を購読し、変化が起きたら 発信元 ID 付き でメッセージを送ります。
// src/store/storeSync.ts
import { shallow } from "zustand/shallow";
import { useSettingsStore } from "./settingsStore";
import { getSettingsChannel } from "./settingsBroadcast";
const SENDER_ID = crypto.randomUUID(); // タブごとにユニーク
export function setupBroadcastOut() {
const channel = getSettingsChannel();
if (!channel) return () => {};
return useSettingsStore.subscribe(
(s) => ({ name: s.name, email: s.email, rate: s.rate }),
(snapshot) => {
channel.postMessage({ from: SENDER_ID, payload: snapshot });
},
{ equalityFn: shallow }
);
}
export { SENDER_ID };
SENDER_ID を タブ単位でユニーク にしておくのが、エコーバック判定で要点になります。
受信側: 自分の発信は無視する
受信したメッセージを setFormData などに流し込みますが、自分が発信したメッセージ で再度 store が更新されると無限ループに陥るため、SENDER_ID で除外します。
import { useSettingsStore } from "./settingsStore";
import { getSettingsChannel, SENDER_ID } from "./settingsBroadcast";
export function setupBroadcastIn() {
const channel = getSettingsChannel();
if (!channel) return () => {};
const handler = (e: MessageEvent) => {
const data = e.data as { from: string; payload: { name: string; email: string; rate: number } };
if (data.from === SENDER_ID) return; // エコー無視
useSettingsStore.getState().setFormData(data.payload);
};
channel.addEventListener("message", handler);
return () => channel.removeEventListener("message", handler);
}
setFormData を呼ぶと subscribe が再び発火しますが、送信側の selector でエコーした値は等価 なので(既に同じ値)shallow 比較で発火しません。万一の二重送信を防ぐ意味でも SENDER_ID ガードは入れておくのが安全です。
SyncBoot から両方を起動する
Zustand 編 で書いた SyncBoot に、ブロードキャストの送受信もまとめます。
// src/components/SyncBoot.tsx
"use client";
import { useEffect } from "react";
import { setupStoreSync } from "@/store/storeSync";
import { setupBroadcastOut } from "@/store/storeSync";
import { setupBroadcastIn } from "@/store/storeSync";
export function SyncBoot() {
useEffect(() => {
const unsub1 = setupStoreSync();
const unsub2 = setupBroadcastOut();
const unsub3 = setupBroadcastIn();
return () => {
unsub1();
unsub2();
unsub3();
};
}, []);
return null;
}
cleanup 関数を 1 つずつ呼ぶ並びにしておけば、ホットリロード時の購読リーク も防げます。
persist と組み合わせる
persist と BroadcastChannel は 役割が違う ので併用が自然です。
- persist: ページをリロードしたときに 値を復元
- BroadcastChannel: 開きっぱなしの別タブに 即時に変化を伝える
両方使う場合、初回ロードの順序 に気をつけます。persist が hydration を終える前にブロードキャストが届くと、復元途中の値が上書きされかねません。onRehydrateStorage を使って、復元完了後に setupBroadcastIn を起動する形が安全です。
persist(
(set) => ({ /* ... */ }),
{
name: "playground.settings",
onRehydrateStorage: () => () => {
// 復元完了後に呼ばれる
setupBroadcastIn();
},
}
);
ポイント
BroadcastChannelは 同一オリジンのタブ間 でリアルタイム同期を実現する標準 API- チャネルは モジュールスコープで 1 つだけ 作る。SSR 用に
typeof window === "undefined"でガードする - 送信側で
SENDER_IDを付け、受信側で 自分の発信を無視 する。エコーバック対策の基本 setupBroadcastOut/setupBroadcastInの cleanup をSyncBootで集中管理 する。ホットリロード時の購読漏れを防ぐ- persist と併用するなら、復元完了後(
onRehydrateStorage)に 受信側を起動 する
関連記事
セットで読むと文脈がつながる関連記事は次のとおりです。
- Zustand 編 —
setupStoreSyncの本体実装 - persist 編 —
onRehydrateStorageの使い方 - 非同期 action 編 — 受信メッセージで save 中だった場合の扱い
- Zustand Test (Build) — 完成例と外部デモ