© TOOLS BOX — Next.js / React / TypeScript コードサンプル集

サンプルガイド
←サンプル一覧
nextjsstate-management

Zustand Test の settingsStore を BroadcastChannel で複数タブに同期する

BroadcastChannel API で別タブの settingsStore に変更をブロードキャストし、エコーバックを避けながら受信側を更新する手順を解説する。

難易度: 中級·更新: 2026-05-07·
zustand

対応バージョン

nextjs 15react 19zustand 5

前提環境

Zustand の subscribe と React の useEffect を理解していること

概要

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) — 完成例と外部デモ

注意点

Playground 追加記事。persist による復元と、BroadcastChannel によるリアルタイムブロードキャストの違い・併用方法を整理する。

リポジトリを見る

関連サンプル

同じテーマや技術スタックを使った実装例

  • Zustand Test のアクションを selector → props で安全に受け渡す

    Zustand のアクションは store 生成時から参照が安定している性質を活かし、selector で取り出して props で子に渡す書き方を解説する。

  • Zustand Test の保存処理を非同期 action と loading / error 状態で扱う

    saveSettings のような非同期処理を store のアクションに閉じ込め、status: 'idle'|'loading'|'error'|'success' を 1 つのフィールドで表現する書き方を解説する。

  • Zustand Test の suggestedRate を subscribeWithSelector で派生する

    settingsStore.rate から detailStore.suggestedRate を導出する派生状態の作り方を、subscribeWithSelector と shallow を軸に解説する。

  • Zustand Test の devtools をアクション命名規則で読みやすくする

    store 名 / アクション名 / replace モードを揃え、Redux DevTools のタイムラインを 4 store 横断で読み解けるようにする命名規則を解説する。

  • Zustand Test の settingsStore に immer を入れて深いネストを楽に更新する

    settingsStore にネストしたフィールドを足すときに、immer middleware で『見た目はミュータブル、実体は immutable』な更新に書き換える手順を解説する。

関連仕様

このサンプルを理解するのに役立つ仕様や概念

  • FrameworkNext.jsReact ベースのフルスタックフレームワーク。SSR・SSG・App Router・API Routes を提供する。
←サンプル一覧に戻る