@stateloom/tab-sync
Cross-tab state synchronization middleware via BroadcastChannel.
Install
pnpm add @stateloom/core @stateloom/store @stateloom/tab-syncnpm install @stateloom/core @stateloom/store @stateloom/tab-syncyarn add @stateloom/core @stateloom/store @stateloom/tab-syncSize: ~0.4 KB gzipped
Overview
The middleware broadcasts local state changes to other browser tabs via BroadcastChannel and applies incoming changes from remote tabs. It handles loop prevention, field filtering, and configurable conflict resolution.
Quick Start
import { createStore } from '@stateloom/store';
import { broadcast } from '@stateloom/tab-sync';
const store = createStore(
(set) => ({
theme: 'light' as 'light' | 'dark',
count: 0,
setTheme: (t: 'light' | 'dark') => set({ theme: t }),
}),
{
middleware: [broadcast({ channel: 'my-app' })],
},
);Guide
Basic Synchronization
Add the broadcast middleware with a channel name. All stores using the same channel name synchronize state across tabs:
import { createStore } from '@stateloom/store';
import { broadcast } from '@stateloom/tab-sync';
const store = createStore(
(set) => ({
theme: 'light' as 'light' | 'dark',
locale: 'en',
count: 0,
setTheme: (t: 'light' | 'dark') => set({ theme: t }),
setLocale: (l: string) => set({ locale: l }),
}),
{
middleware: [broadcast({ channel: 'app-state' })],
},
);When store.getState().setTheme('dark') is called in Tab 1, Tab 2's store automatically receives { theme: 'dark' }.
Filtering Synchronized Fields
Use the filter option to sync only specific fields. This prevents large or transient state (like loading indicators or form drafts) from being broadcast:
broadcast({
channel: 'app-state',
filter: (state) => ({
theme: state.theme,
locale: state.locale,
// count, loading, etc. are NOT synced
}),
});TIP
The filter controls what is sent, not what is received. Incoming state from other tabs is always applied as-is after conflict resolution.
Conflict Resolution
Three strategies control how incoming state is merged:
Last-Write-Wins (Default)
Always apply incoming state:
broadcast({
channel: 'app-state',
conflictResolution: 'last-write-wins',
});Timestamp-Based
Only apply if the incoming message has a newer timestamp than the most recently applied remote update:
broadcast({
channel: 'app-state',
conflictResolution: 'timestamp',
});Custom Resolver
Implement custom merge logic:
broadcast({
channel: 'app-state',
conflictResolution: (incoming, current) => ({
...incoming,
// Keep local count if it's higher
count: Math.max(incoming.count ?? current.count, current.count),
}),
});API Reference
broadcast<T>(options: BroadcastOptions<T>): Middleware<T>
Create a cross-tab state synchronization middleware.
Parameters:
| Parameter | Type | Description | Default |
|---|---|---|---|
options | BroadcastOptions<T> | Configuration object. | -- |
options.channel | string | Name of the BroadcastChannel. | -- |
options.filter | (state: T) => Partial<T> | Filter which fields to broadcast. | All fields |
options.conflictResolution | ConflictResolution<T> | How to resolve incoming state. | 'last-write-wins' |
Returns: Middleware<T> -- a middleware instance for createStore.
import { createStore } from '@stateloom/store';
import { broadcast } from '@stateloom/tab-sync';
const store = createStore(
(set) => ({
theme: 'light' as 'light' | 'dark',
count: 0,
setTheme: (t: 'light' | 'dark') => set({ theme: t }),
}),
{
middleware: [
broadcast({
channel: 'my-app',
filter: (state) => ({ theme: state.theme }),
conflictResolution: 'timestamp',
}),
],
},
);Key behaviors:
- Gracefully degrades to no-op when
BroadcastChannelis unavailable (SSR, older browsers) - Loop prevention: each instance has a unique sender ID; incoming messages from the same ID are ignored
- The
isApplyingRemoteflag prevents re-broadcasting state received from other tabs - Filter controls what is sent, not what is received
- With
'timestamp'resolution, messages older than the most recent applied remote update are dropped - The channel is closed on
onDestroy(store cleanup)
BroadcastOptions<T> (interface)
Configuration for the broadcast() middleware.
interface BroadcastOptions<T> {
readonly channel: string;
readonly filter?: (state: T) => Partial<T>;
readonly conflictResolution?: ConflictResolution<T>;
}| Property | Type | Description | Default |
|---|---|---|---|
channel | string | BroadcastChannel name. All stores on the same channel sync. | -- |
filter | (state: T) => Partial<T> | Selects fields to broadcast after each update. | All fields |
conflictResolution | ConflictResolution<T> | Strategy for resolving incoming state. | 'last-write-wins' |
ConflictResolution<T> (type)
type ConflictResolution<T> = 'last-write-wins' | 'timestamp' | ConflictResolver<T>;'last-write-wins'-- Always apply incoming state (default)'timestamp'-- Only apply if the message timestamp is newerConflictResolver<T>-- Custom function(incoming, current) => Partial<T>
ConflictResolver<T> (type)
type ConflictResolver<T> = (incoming: Partial<T>, current: T) => Partial<T>;Custom function that receives the incoming partial state and the current local state, returning the resolved partial to apply.
BroadcastMessage<T> (interface)
Wire format for messages sent across the channel.
interface BroadcastMessage<T> {
readonly senderId: string;
readonly timestamp: number;
readonly state: Partial<T>;
}Patterns
Theme and Locale Sync
import { createStore } from '@stateloom/store';
import { broadcast } from '@stateloom/tab-sync';
interface AppState {
theme: 'light' | 'dark';
locale: string;
notifications: number;
setTheme: (t: 'light' | 'dark') => void;
setLocale: (l: string) => void;
}
const store = createStore<AppState>(
(set) => ({
theme: 'light',
locale: 'en',
notifications: 0,
setTheme: (t) => set({ theme: t }),
setLocale: (l) => set({ locale: l }),
}),
{
middleware: [
broadcast({
channel: 'user-prefs',
filter: (state) => ({ theme: state.theme, locale: state.locale }),
}),
],
},
);Auth State Sync
import { createStore } from '@stateloom/store';
import { broadcast } from '@stateloom/tab-sync';
const authStore = createStore(
(set) => ({
isAuthenticated: false,
userId: null as string | null,
login: (id: string) => set({ isAuthenticated: true, userId: id }),
logout: () => set({ isAuthenticated: false, userId: null }),
}),
{
middleware: [
broadcast({
channel: 'auth',
conflictResolution: 'timestamp',
}),
],
},
);
// Logging out in one tab logs out all tabsCombining with Persist
import { createStore } from '@stateloom/store';
import { persist } from '@stateloom/persist';
import { broadcast } from '@stateloom/tab-sync';
const store = createStore(
(set) => ({
theme: 'light' as 'light' | 'dark',
setTheme: (t: 'light' | 'dark') => set({ theme: t }),
}),
{
middleware: [persist({ key: 'user-prefs' }), broadcast({ channel: 'user-prefs' })],
},
);
// State survives page reload (persist) AND syncs across tabs (broadcast)How It Works
Message Flow
Loop Prevention
Two mechanisms prevent infinite broadcast loops:
- Sender ID: Each middleware instance generates a unique ID (via
crypto.randomUUIDor fallback). Messages with the same sender ID as the local instance are ignored. - isApplyingRemote flag: While applying incoming state,
onSetskips broadcasting. This prevents the applied state from being re-sent.
Graceful Degradation
The middleware checks typeof BroadcastChannel during init(). In environments where it's unavailable (SSR, Web Workers without BroadcastChannel, older browsers), the middleware silently becomes a no-op.
TypeScript
import { broadcast } from '@stateloom/tab-sync';
import type { BroadcastOptions, ConflictResolver } from '@stateloom/tab-sync';
import type { Middleware } from '@stateloom/store';
import { expectTypeOf } from 'vitest';
interface AppState {
theme: 'light' | 'dark';
count: number;
}
// broadcast returns Middleware<T>
const mw = broadcast<AppState>({ channel: 'test' });
expectTypeOf(mw).toMatchTypeOf<Middleware<AppState>>();
// ConflictResolver is typed
const resolver: ConflictResolver<AppState> = (incoming, current) => ({
...incoming,
count: Math.max(incoming.count ?? current.count, current.count),
});
expectTypeOf(resolver).toEqualTypeOf<ConflictResolver<AppState>>();
// filter function receives full state, returns partial
const opts: BroadcastOptions<AppState> = {
channel: 'test',
filter: (state) => ({ theme: state.theme }),
};When to Use
| Scenario | Why @stateloom/tab-sync |
|---|---|
| Theme/locale preferences shared across tabs | Instant sync without page reload |
| Auth state (login/logout affects all tabs) | timestamp resolution prevents stale auth |
| Collaborative features (cursor position, presence) | Custom conflict resolution for merging |
| Shopping cart shared across tabs | last-write-wins or custom merge |
Use @stateloom/tab-sync when you need state shared across browser tabs in real time. For persistence across page reloads, use @stateloom/persist. For server-side state, use @stateloom/server. The two can be combined for persist + sync.