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 アプリケーションでは、ユーザーはリアルタイムな反応を期待しています。特に以下のような場面では、即座の同期が重要です。
リアルタイム同期が重要な場面
- E コマース: カート操作、在庫状況の更新
- チャット・メッセージング: 新着メッセージの通知
- 設定管理: テーマ、言語設定の変更
- 認証状態: ログイン・ログアウト状況
- 通知システム: アラート、お知らせの表示
従来の方法では、これらのリアルタイム要求に応えることが困難でした。遅延が発生したり、複雑な実装が必要になったりと、開発効率と UX の両面で課題がありました。
解決策
BroadcastChannelとJotaiの組み合わせによる解決アプローチ
BroadcastChannel API と Jotai を組み合わせることで、これらの課題を効率的に解決できます。この組み合わせの核となるアイデアは以下の通りです。
- Jotai の atom 更新を BroadcastChannel で他のタブに通知
- 他のタブでメッセージを受信したら、対応する atom を更新
- シンプルな実装でリアルタイム同期を実現
状態同期の設計パターン
効果的なタブ間同期を実現するための設計パターンをご紹介します。
パターン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 を活用した堅牢な実装 | ★★ |
ベストプラクティス
-
メッセージ送信者の識別
- 自分が送信したメッセージは処理しない
- 無限ループを防ぐために必須
-
リソース管理の徹底
- BroadcastChannel の適切なクローズ
- イベントリスナーのクリーンアップ
-
段階的な実装
- まずは単純な例から始める
- 機能追加は段階的に行う
-
デバッグ機能の実装
- 同期状況を可視化する仕組み
- 開発時の問題特定を容易にする
今後の発展性
この実装をベースに、さらに高度な機能を追加できます。
- 永続化との連携: localStorage や IndexedDB との組み合わせ
- サーバーサイド同期: WebSocket や SSE との統合
- 競合解決: 同時更新時の適切な処理
- パフォーマンス監視: 同期処理の性能測定
BroadcastChannel と Jotai の組み合わせにより、シンプルでありながら強力なタブ間同期システムを構築できます。ユーザー体験の向上と開発効率の両立を実現する、実用的なソリューションとしてご活用ください。
関連リンク
- article
BroadcastChannel でタブ間同期:Jotai 状態をリアルタイムで共有する
- article
jotai IndexedDB・localForage と連携した大容量永続化パターン(atom を超えて)
- article
jotai/optics で深いネストを安全に操作する実践ガイド
- article
React Server Components 時代に Jotai はどう進化する?サーバーとクライアントをまたぐ状態管理の未来
- article
Jotai を 120%活用するユーティリティライブラリ(jotai-immer, jotai-xstate, jotai-form)の世界
- article
Redux Toolkit から Jotai への移行は可能か?具体的なリファクタリング戦略とコード例
- review
今の自分に満足していますか?『持たざる者の逆襲 まだ何者でもない君へ』溝口勇児
- review
ついに語られた業界の裏側!『フジテレビの正体』堀江貴文が描くテレビ局の本当の姿
- review
愛する勇気を持てば人生が変わる!『幸せになる勇気』岸見一郎・古賀史健のアドラー実践編で真の幸福を手に入れる
- review
週末を変えれば年収も変わる!『世界の一流は「休日」に何をしているのか』越川慎司の一流週末メソッド
- review
新しい自分に会いに行こう!『自分の変え方』村岡大樹の認知科学コーチングで人生リセット
- review
科学革命から AI 時代へ!『サピエンス全史 下巻』ユヴァル・ノア・ハラリが予見する人類の未来