T-CREATOR

BroadcastChannel でタブ間同期:Jotai 状態をリアルタイムで共有する

BroadcastChannel でタブ間同期:Jotai 状態をリアルタイムで共有する

Web アプリケーションでユーザーが複数のタブを開いている時、タブ間で状態を共有したい場面があります。例えば、ショッピングカートに商品を追加した際に、他のタブでも即座にカート内容が更新される。ユーザー設定を変更した時に、全てのタブで新しい設定が反映される。そんなリアルタイムなタブ間同期を実現したくはありませんでしょうか。

今回は、モダンな状態管理ライブラリである Jotai と、ブラウザ標準 API の BroadcastChannel を組み合わせて、シンプルでありながら強力なタブ間同期システムを構築する方法をご紹介いたします。この組み合わせにより、複雑な設定なしで高速なリアルタイム同期が実現できます。

背景

複数タブでの状態共有の重要性

現代の Web アプリケーションでは、ユーザーが同じサービスを複数のタブで開いて利用することが当たり前になっています。SNS、EC サイト、管理画面など、ユーザーの行動パターンを考えると、タブ間での状態同期は必須の機能と言えるでしょう。

状態が同期されていない場合、以下のような問題が発生します。

  • データの不整合: 一方のタブでデータを更新しても、他のタブには反映されない
  • ユーザー体験の悪化: タブごとに異なる情報が表示され、混乱を招く
  • 作業効率の低下: 手動でページを更新する必要があり、作業が中断される

Jotaiの状態管理の特徴

Jotai は React アプリケーションのための原始的で柔軟な状態管理ライブラリです。従来の Redux のような重厚な仕組みとは異なり、シンプルな atom ベースの設計が特徴です。

typescriptimport { atom } from 'jotai'

// シンプルなatom定義
const countAtom = atom(0)
const userAtom = atom({ name: '', email: '' })

Jotai の主な特徴は以下の通りです。

特徴詳細
原始的(Atomic)状態を小さな atom 単位で管理
ボイラープレートレス最小限のコードで状態管理が可能
TypeScript フレンドリー優れた型推論と型安全性
パフォーマンス最適必要な部分のみが再レンダリング

BroadcastChannel APIの役割と仕組み

BroadcastChannel API は、同一オリジン内の異なるブラウジングコンテキスト(タブ、ウィンドウ、iframe など)間でメッセージをやり取りするためのブラウザ標準 API です。

基本的な使い方は以下のようになります。

typescript// チャンネル作成
const channel = new BroadcastChannel('my-channel')

// メッセージ送信
channel.postMessage({ type: 'update', data: 'hello' })

// メッセージ受信
channel.addEventListener('message', (event) => {
  console.log('受信:', event.data)
})

以下の図で BroadcastChannel の基本的な仕組みを確認してみましょう。

mermaidflowchart LR
    tab1[タブ1] -->|postMessage| channel[BroadcastChannel]
    channel -->|message event| tab2[タブ2]
    channel -->|message event| tab3[タブ3]
    tab2 -->|postMessage| channel
    tab3 -->|postMessage| channel

図で理解できる要点:

  • 任意のタブからメッセージを送信可能
  • 送信者以外の全てのタブが自動受信
  • 双方向通信でリアルタイム同期を実現

課題

従来のタブ間同期方法の限界

従来、タブ間での状態同期には以下のような方法が使われていました。

localStorage を使った同期

typescript// 状態をlocalStorageに保存
localStorage.setItem('appState', JSON.stringify(state))

// 定期的にlocalStorageをポーリング
setInterval(() => {
  const newState = JSON.parse(localStorage.getItem('appState') || '{}')
  // 状態を比較して更新...
}, 1000)

この方法には以下の問題があります。

  • リアルタイム性の欠如: ポーリング間隔によって同期に遅延が発生
  • パフォーマンス問題: 不要な定期処理がリソースを消費
  • 複雑な状態比較: 変更検知のためのロジックが複雑化

storage イベントを使った同期

typescript// storageイベントリスナー
window.addEventListener('storage', (e) => {
  if (e.key === 'appState') {
    // 状態を更新...
  }
})

storage イベントは改善されたアプローチですが、以下の制約があります。

  • 同一タブでは発火しない: イベントを発生させたタブでは storage イベントが発生しない
  • データ形式の制約: 文字列のみの保存で、複雑なオブジェクトの直接保存ができない
  • 削除処理の複雑さ: 不要になったデータの削除タイミングが困難

Jotaiの状態がタブ間で分離される問題

Jotai は設計上、各タブで独立した状態管理を行います。これは通常の使用では利点ですが、タブ間同期が必要な場合は課題となります。

typescript// タブ1でカウントを更新
const [count, setCount] = useAtom(countAtom)
setCount(10) // タブ1のみで状態更新

// タブ2では依然として初期値のまま
// count = 0

この分離により、以下の問題が発生します。

問題説明影響
状態の不整合タブごとに異なる状態値データの信頼性低下
重複処理同じ初期化処理を各タブで実行パフォーマンス低下
ユーザー混乱タブによって異なる表示UX の悪化

リアルタイム性の要求に対する課題

モダンな Web アプリケーションでは、ユーザーはリアルタイムな反応を期待しています。特に以下のような場面では、即座の同期が重要です。

リアルタイム同期が重要な場面

  1. E コマース: カート操作、在庫状況の更新
  2. チャット・メッセージング: 新着メッセージの通知
  3. 設定管理: テーマ、言語設定の変更
  4. 認証状態: ログイン・ログアウト状況
  5. 通知システム: アラート、お知らせの表示

従来の方法では、これらのリアルタイム要求に応えることが困難でした。遅延が発生したり、複雑な実装が必要になったりと、開発効率と UX の両面で課題がありました。

解決策

BroadcastChannelとJotaiの組み合わせによる解決アプローチ

BroadcastChannel API と Jotai を組み合わせることで、これらの課題を効率的に解決できます。この組み合わせの核となるアイデアは以下の通りです。

  1. Jotai の atom 更新を BroadcastChannel で他のタブに通知
  2. 他のタブでメッセージを受信したら、対応する atom を更新
  3. シンプルな実装でリアルタイム同期を実現

状態同期の設計パターン

効果的なタブ間同期を実現するための設計パターンをご紹介します。

パターン1: 単方向同期パターン

最もシンプルなパターンで、状態更新を一方向に伝播させます。

mermaidsequenceDiagram
    participant T1 as タブ1
    participant BC as BroadcastChannel
    participant T2 as タブ2
    participant T3 as タブ3
    
    T1->>T1: atom更新
    T1->>BC: メッセージ送信
    BC->>T2: メッセージ受信
    BC->>T3: メッセージ受信
    T2->>T2: atom更新
    T3->>T3: atom更新

パターン2: 双方向同期パターン

どのタブからでも更新が可能で、全てのタブで同期されます。

typescript// 同期対象のatom
const syncAtom = atom(
  // getter: 現在の値を返す
  (get) => get(baseAtom),
  // setter: 値を設定し、他のタブに通知
  (get, set, newValue) => {
    set(baseAtom, newValue)
    // BroadcastChannelで他のタブに通知
    channel.postMessage({ type: 'atom-update', value: newValue })
  }
)

効率的なメッセージングアーキテクチャ

大量の状態やリアルタイム更新に対応するため、効率的なメッセージング設計が重要です。

メッセージフォーマットの標準化

typescriptinterface SyncMessage {
  type: 'atom-update' | 'bulk-update' | 'reset'
  atomKey: string
  value: any
  timestamp: number
  tabId: string
}

選択的同期の実装

全ての atom を同期する必要はありません。同期が必要な atom のみを選択的に処理します。

typescript// 同期対象のatomを登録
const syncAtoms = new Map([
  ['user', userAtom],
  ['cart', cartAtom],
  ['settings', settingsAtom]
])

図で理解できる要点:

  • 状態更新と同時にメッセージ送信
  • 受信側で対象atomを特定して更新
  • タイムスタンプで競合状態を回避

具体例

基本的な実装例

まずは最もシンプルな例から始めましょう。カウンターをタブ間で同期する実装です。

必要なパッケージのインストール

bashyarn add jotai
yarn add -D @types/react @types/react-dom

基本のatom定義

typescriptimport { atom } from 'jotai'

// 基本となるカウンターatom
const baseCountAtom = atom(0)

// BroadcastChannel インスタンス
const channel = new BroadcastChannel('jotai-sync')

同期機能付きatom作成

typescriptimport { atom } from 'jotai'

// メッセージの型定義
interface SyncMessage {
  type: 'count-update'
  value: number
  tabId: string
}

// タブIDを生成(送信者を識別するため)
const tabId = Math.random().toString(36).substring(2)

同期機能を持つ atom を作成します。

typescript// 同期機能付きカウンターatom
const syncCountAtom = atom(
  // getter: 現在の値を取得
  (get) => get(baseCountAtom),
  // setter: 値を設定し、他のタブに通知
  (get, set, newValue: number) => {
    // 自分のタブの状態を更新
    set(baseCountAtom, newValue)
    
    // 他のタブにメッセージを送信
    const message: SyncMessage = {
      type: 'count-update',
      value: newValue,
      tabId
    }
    channel.postMessage(message)
  }
)

メッセージ受信の処理

typescriptimport { useSetAtom } from 'jotai'
import { useEffect } from 'react'

// 同期のセットアップを行うカスタムフック
function useBroadcastSync() {
  const setCount = useSetAtom(baseCountAtom)
  
  useEffect(() => {
    // メッセージ受信時の処理
    const handleMessage = (event: MessageEvent<SyncMessage>) => {
      const { type, value, tabId: senderTabId } = event.data
      
      // 自分が送信したメッセージは無視
      if (senderTabId === tabId) {
        return
      }
      
      // カウンター更新メッセージの処理
      if (type === 'count-update') {
        setCount(value)
      }
    }
    
    // リスナーを登録
    channel.addEventListener('message', handleMessage)
    
    // クリーンアップ
    return () => {
      channel.removeEventListener('message', handleMessage)
    }
  }, [setCount])
}

React コンポーネントでの使用

typescriptimport { useAtom } from 'jotai'

function Counter() {
  const [count, setCount] = useAtom(syncCountAtom)
  
  // 同期機能を有効化
  useBroadcastSync()
  
  return (
    <div>
      <h2>同期カウンター</h2>
      <p>現在の値: {count}</p>
      <button onClick={() => setCount(count + 1)}>
        +1
      </button>
      <button onClick={() => setCount(count - 1)}>
        -1
      </button>
      <button onClick={() => setCount(0)}>
        リセット
      </button>
    </div>
  )
}

カスタムフックの作成

再利用可能なカスタムフックを作成して、他の atom でも同期機能を簡単に使えるようにします。

ジェネリックな同期フック

typescriptimport { atom, Atom, WritableAtom } from 'jotai'
import { useSetAtom } from 'jotai'
import { useEffect } from 'react'

// 汎用的な同期メッセージ型
interface GenericSyncMessage<T = any> {
  type: string
  atomKey: string
  value: T
  tabId: string
  timestamp: number
}
typescript// 同期可能なatomを作成するファクトリ関数
function createSyncAtom<T>(
  baseAtom: WritableAtom<T, [T], void>,
  atomKey: string
): WritableAtom<T, [T], void> {
  const channel = new BroadcastChannel('jotai-sync')
  const tabId = Math.random().toString(36).substring(2)
  
  return atom(
    (get) => get(baseAtom),
    (get, set, newValue: T) => {
      set(baseAtom, newValue)
      
      const message: GenericSyncMessage<T> = {
        type: 'atom-update',
        atomKey,
        value: newValue,
        tabId,
        timestamp: Date.now()
      }
      channel.postMessage(message)
    }
  )
}

同期管理フック

typescript// 複数のatomの同期を管理するフック
function useSyncManager(syncConfig: Record<string, WritableAtom<any, [any], void>>) {
  const setAtoms = Object.entries(syncConfig).reduce(
    (acc, [key, atom]) => ({ ...acc, [key]: useSetAtom(atom) }),
    {} as Record<string, (value: any) => void>
  )
  
  useEffect(() => {
    const channel = new BroadcastChannel('jotai-sync')
    const tabId = Math.random().toString(36).substring(2)
    
    const handleMessage = (event: MessageEvent<GenericSyncMessage>) => {
      const { atomKey, value, tabId: senderTabId, timestamp } = event.data
      
      // 自分が送信したメッセージは無視
      if (senderTabId === tabId) return
      
      // 対象のatomが登録されているかチェック
      if (atomKey in setAtoms) {
        setAtoms[atomKey](value)
      }
    }
    
    channel.addEventListener('message', handleMessage)
    
    return () => {
      channel.removeEventListener('message', handleMessage)
      channel.close()
    }
  }, [setAtoms])
}

実際の動作確認

実装したタブ間同期機能の動作を確認してみましょう。

テスト用のアプリケーション

typescriptimport { Provider } from 'jotai'

// 複数の同期対象atom
const userAtom = atom({ name: '', email: '' })
const cartAtom = atom<string[]>([])
const themeAtom = atom<'light' | 'dark'>('light')

// 同期版のatomを作成
const syncUserAtom = createSyncAtom(userAtom, 'user')
const syncCartAtom = createSyncAtom(cartAtom, 'cart')
const syncThemeAtom = createSyncAtom(themeAtom, 'theme')
typescriptfunction App() {
  // 同期設定
  const syncConfig = {
    user: userAtom,
    cart: cartAtom,
    theme: themeAtom
  }
  
  // 同期機能を有効化
  useSyncManager(syncConfig)
  
  return (
    <Provider>
      <div>
        <UserProfile />
        <ShoppingCart />
        <ThemeToggle />
      </div>
    </Provider>
  )
}

デバッグ用のコンポーネント

typescriptfunction DebugPanel() {
  const [user] = useAtom(syncUserAtom)
  const [cart] = useAtom(syncCartAtom)
  const [theme] = useAtom(syncThemeAtom)
  
  return (
    <div style={{ 
      position: 'fixed', 
      bottom: 20, 
      right: 20, 
      background: '#f0f0f0', 
      padding: 10 
    }}>
      <h4>同期状況デバッグ</h4>
      <p>ユーザー: {user.name}</p>
      <p>カート: {cart.length}個</p>
      <p>テーマ: {theme}</p>
      <p>タブID: {tabId.substring(0, 8)}</p>
    </div>
  )
}

パフォーマンス最適化

タブ間同期のパフォーマンスを最適化するためのテクニックをご紹介します。

更新頻度の制限(デバウンス)

高頻度で更新される状態の場合、メッセージ送信を制限します。

typescriptimport { debounce } from 'lodash-es'

// デバウンス機能付きの同期atom
function createDebouncedSyncAtom<T>(
  baseAtom: WritableAtom<T, [T], void>,
  atomKey: string,
  delay: number = 300
) {
  const channel = new BroadcastChannel('jotai-sync')
  const tabId = Math.random().toString(36).substring(2)
  
  // デバウンス処理されたメッセージ送信
  const debouncedSend = debounce((value: T) => {
    const message: GenericSyncMessage<T> = {
      type: 'atom-update',
      atomKey,
      value,
      tabId,
      timestamp: Date.now()
    }
    channel.postMessage(message)
  }, delay)
  
  return atom(
    (get) => get(baseAtom),
    (get, set, newValue: T) => {
      set(baseAtom, newValue)
      debouncedSend(newValue)
    }
  )
}

選択的同期の実装

typescript// 同期条件を指定可能なatom
function createConditionalSyncAtom<T>(
  baseAtom: WritableAtom<T, [T], void>,
  atomKey: string,
  shouldSync: (oldValue: T, newValue: T) => boolean
) {
  const channel = new BroadcastChannel('jotai-sync')
  const tabId = Math.random().toString(36).substring(2)
  
  return atom(
    (get) => get(baseAtom),
    (get, set, newValue: T) => {
      const oldValue = get(baseAtom)
      set(baseAtom, newValue)
      
      // 条件を満たす場合のみ同期
      if (shouldSync(oldValue, newValue)) {
        const message: GenericSyncMessage<T> = {
          type: 'atom-update',
          atomKey,
          value: newValue,
          tabId,
          timestamp: Date.now()
        }
        channel.postMessage(message)
      }
    }
  )
}

メモリリーク対策

typescript// リソース管理を含む同期フック
function useManagedSync() {
  const channelRef = useRef<BroadcastChannel | null>(null)
  
  useEffect(() => {
    // チャンネル作成
    channelRef.current = new BroadcastChannel('jotai-sync')
    
    // ページ離脱時のクリーンアップ
    const handleBeforeUnload = () => {
      channelRef.current?.close()
    }
    
    window.addEventListener('beforeunload', handleBeforeUnload)
    
    return () => {
      window.removeEventListener('beforeunload', handleBeforeUnload)
      channelRef.current?.close()
    }
  }, [])
}

図で理解できる要点:

  • デバウンスで送信頻度を制御
  • 条件付き同期で不要な通信を削減
  • リソース管理でメモリリークを防止

まとめ

実装のポイント

BroadcastChannel と Jotai を組み合わせたタブ間同期の実装において、重要なポイントをまとめます。

設計における重要事項

ポイント詳細重要度
シンプルさの維持複雑な仕組みより、理解しやすい実装を優先★★★
パフォーマンスデバウンスや選択的同期でリソース使用量を最適化★★★
エラーハンドリング通信エラーや不正なメッセージに対する対策★★
型安全性TypeScript を活用した堅牢な実装★★

ベストプラクティス

  1. メッセージ送信者の識別

    • 自分が送信したメッセージは処理しない
    • 無限ループを防ぐために必須
  2. リソース管理の徹底

    • BroadcastChannel の適切なクローズ
    • イベントリスナーのクリーンアップ
  3. 段階的な実装

    • まずは単純な例から始める
    • 機能追加は段階的に行う
  4. デバッグ機能の実装

    • 同期状況を可視化する仕組み
    • 開発時の問題特定を容易にする

今後の発展性

この実装をベースに、さらに高度な機能を追加できます。

  • 永続化との連携: localStorage や IndexedDB との組み合わせ
  • サーバーサイド同期: WebSocket や SSE との統合
  • 競合解決: 同時更新時の適切な処理
  • パフォーマンス監視: 同期処理の性能測定

BroadcastChannel と Jotai の組み合わせにより、シンプルでありながら強力なタブ間同期システムを構築できます。ユーザー体験の向上と開発効率の両立を実現する、実用的なソリューションとしてご活用ください。

関連リンク