Skip to content

@stateloom/tab-sync

Cross-tab state synchronization middleware via BroadcastChannel.

Install

bash
pnpm add @stateloom/core @stateloom/store @stateloom/tab-sync
bash
npm install @stateloom/core @stateloom/store @stateloom/tab-sync
bash
yarn add @stateloom/core @stateloom/store @stateloom/tab-sync

Size: ~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

typescript
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:

typescript
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:

typescript
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:

typescript
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:

typescript
broadcast({
  channel: 'app-state',
  conflictResolution: 'timestamp',
});

Custom Resolver

Implement custom merge logic:

typescript
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:

ParameterTypeDescriptionDefault
optionsBroadcastOptions<T>Configuration object.--
options.channelstringName of the BroadcastChannel.--
options.filter(state: T) => Partial<T>Filter which fields to broadcast.All fields
options.conflictResolutionConflictResolution<T>How to resolve incoming state.'last-write-wins'

Returns: Middleware<T> -- a middleware instance for createStore.

typescript
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 BroadcastChannel is unavailable (SSR, older browsers)
  • Loop prevention: each instance has a unique sender ID; incoming messages from the same ID are ignored
  • The isApplyingRemote flag 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.

typescript
interface BroadcastOptions<T> {
  readonly channel: string;
  readonly filter?: (state: T) => Partial<T>;
  readonly conflictResolution?: ConflictResolution<T>;
}
PropertyTypeDescriptionDefault
channelstringBroadcastChannel name. All stores on the same channel sync.--
filter(state: T) => Partial<T>Selects fields to broadcast after each update.All fields
conflictResolutionConflictResolution<T>Strategy for resolving incoming state.'last-write-wins'

ConflictResolution<T> (type)

typescript
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 newer
  • ConflictResolver<T> -- Custom function (incoming, current) => Partial<T>

ConflictResolver<T> (type)

typescript
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.

typescript
interface BroadcastMessage<T> {
  readonly senderId: string;
  readonly timestamp: number;
  readonly state: Partial<T>;
}

Patterns

Theme and Locale Sync

typescript
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

typescript
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 tabs

Combining with Persist

typescript
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:

  1. Sender ID: Each middleware instance generates a unique ID (via crypto.randomUUID or fallback). Messages with the same sender ID as the local instance are ignored.
  2. isApplyingRemote flag: While applying incoming state, onSet skips 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

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

ScenarioWhy @stateloom/tab-sync
Theme/locale preferences shared across tabsInstant 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 tabslast-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.