TLDR
Create a Pinia plugin that enables state synchronization across browser tabs using the BroadcastChannel API. The plugin allows you to mark specific stores for cross-tab syncing and handles state updates automatically with timestamp-based conflict resolution.
Introduction
In modern web applications, users often work with multiple browser tabs open. When using Pinia for state management, we sometimes need to ensure that state changes in one tab are reflected across all open instances of our application. This post will guide you through creating a plugin that adds cross-tab state synchronization to your Pinia stores.
Understanding Pinia Plugins
A Pinia plugin is a function that extends the functionality of Pinia stores. Plugins are powerful tools that help:
- Reduce code duplication
- Add reusable functionality across stores
- Keep store definitions clean and focused
- Implement cross-cutting concerns
Cross-Tab Communication with BroadcastChannel
The BroadcastChannel API provides a simple way to send messages between different browser contexts (tabs, windows, or iframes) of the same origin. It’s perfect for our use case of synchronizing state across tabs.
Key features of BroadcastChannel:
- Built-in browser API
- Same-origin security model
- Simple pub/sub messaging pattern
- No need for external dependencies
How BroadcastChannel Works
The BroadcastChannel API operates on a simple principle: any browsing context (window, tab, iframe, or worker) can join a channel by creating a BroadcastChannel
object with the same channel name. Once joined:
- Messages are sent using the
postMessage()
method - Messages are received through the
onmessage
event handler - Contexts can leave the channel using the
close()
method
Implementing the Plugin
Store Configuration
To use our plugin, stores need to opt-in to state sharing through configuration:
import { function ref<T>(value: T): [T] extends [Ref] ? IfAny<T, Ref<T>, T> : Ref<UnwrapRef<T>, UnwrapRef<T> | T> (+1 overload)
Takes an inner value and returns a reactive and mutable ref object, which
has a single property `.value` that points to the inner value.ref, const computed: {
<T>(getter: ComputedGetter<T>, debugOptions?: DebuggerOptions): ComputedRef<T>;
<T, S = T>(options: WritableComputedOptions<T, S>, debugOptions?: DebuggerOptions): WritableComputedRef<T, S>;
}
computed } from 'vue'
import { import defineStore
defineStore } from 'pinia'
export const const useCounterStore: any
useCounterStore = import defineStore
defineStore(
'counter',
() => {
const const count: Ref<number, number>
count = ref<number>(value: number): Ref<number, number> (+1 overload)
Takes an inner value and returns a reactive and mutable ref object, which
has a single property `.value` that points to the inner value.ref(0)
const const doubleCount: ComputedRef<number>
doubleCount = computed<number>(getter: ComputedGetter<number>, debugOptions?: DebuggerOptions): ComputedRef<number> (+1 overload)
Takes a getter function and returns a readonly reactive ref object for the
returned value from the getter. It can also take an object with get and set
functions to create a writable ref object.computed(() => const count: Ref<number, number>
count.Ref<number, number>.value: number
value * 2)
function function (local function) increment(): void
increment() {
const count: Ref<number, number>
count.Ref<number, number>.value: number
value++
}
return { count: Ref<number, number>
count, doubleCount: ComputedRef<number>
doubleCount, increment: () => void
increment }
},
{
share: {
enable: boolean;
initialize: boolean;
}
share: {
enable: boolean
enable: true,
initialize: boolean
initialize: true,
},
},
)
The share
option enables cross-tab synchronization and controls whether the store should initialize its state from other tabs.
Plugin Registration main.ts
Register the plugin when creating your Pinia instance:
import { import createPinia
createPinia } from 'pinia'
import { import PiniaSharedState
PiniaSharedState } from './plugin/plugin'
const const pinia: any
pinia = import createPinia
createPinia()
const pinia: any
pinia.use(import PiniaSharedState
PiniaSharedState)
Plugin Implementation plugin/plugin.ts
Here’s our complete plugin implementation with TypeScript support:
import type { import PiniaPluginContext
PiniaPluginContext, import StateTree
StateTree, import DefineStoreOptions
DefineStoreOptions } from 'pinia'
type type Serializer<T extends StateTree> = {
serialize: (value: T) => string;
deserialize: (value: string) => T;
}
Serializer<function (type parameter) T in type Serializer<T extends StateTree>
T extends import StateTree
StateTree> = {
serialize: (value: T) => string
serialize: (value: T extends StateTree
value: function (type parameter) T in type Serializer<T extends StateTree>
T) => string
deserialize: (value: string) => T
deserialize: (value: string
value: string) => function (type parameter) T in type Serializer<T extends StateTree>
T
}
interface BroadcastMessage {
BroadcastMessage.type: "STATE_UPDATE" | "SYNC_REQUEST"
type: 'STATE_UPDATE' | 'SYNC_REQUEST'
BroadcastMessage.timestamp?: number | undefined
timestamp?: number
BroadcastMessage.state?: string | undefined
state?: string
}
type type PluginOptions<T extends StateTree> = {
enable?: boolean;
initialize?: boolean;
serializer?: Serializer<T>;
}
PluginOptions<function (type parameter) T in type PluginOptions<T extends StateTree>
T extends import StateTree
StateTree> = {
enable?: boolean | undefined
enable?: boolean
initialize?: boolean | undefined
initialize?: boolean
serializer?: Serializer<T> | undefined
serializer?: type Serializer<T extends StateTree> = {
serialize: (value: T) => string;
deserialize: (value: string) => T;
}
Serializer<function (type parameter) T in type PluginOptions<T extends StateTree>
T>
}
export interface interface StoreOptions<S extends StateTree = StateTree, G = object, A = object>
StoreOptions<function (type parameter) S in StoreOptions<S extends StateTree = StateTree, G = object, A = object>
S extends import StateTree
StateTree = import StateTree
StateTree, function (type parameter) G in StoreOptions<S extends StateTree = StateTree, G = object, A = object>
G = object, function (type parameter) A in StoreOptions<S extends StateTree = StateTree, G = object, A = object>
A = object>
extends import DefineStoreOptions
DefineStoreOptions<string, function (type parameter) S in StoreOptions<S extends StateTree = StateTree, G = object, A = object>
S, function (type parameter) G in StoreOptions<S extends StateTree = StateTree, G = object, A = object>
G, function (type parameter) A in StoreOptions<S extends StateTree = StateTree, G = object, A = object>
A> {
StoreOptions<S extends StateTree = StateTree, G = object, A = object>.share?: PluginOptions<S> | undefined
share?: type PluginOptions<T extends StateTree> = {
enable?: boolean;
initialize?: boolean;
serializer?: Serializer<T>;
}
PluginOptions<function (type parameter) S in StoreOptions<S extends StateTree = StateTree, G = object, A = object>
S>
}
// Add type extension for Pinia
declare module 'pinia' {
// eslint-disable-next-line @typescript-eslint/no-unused-vars
export interface interface DefineStoreOptionsBase<S, Store>
DefineStoreOptionsBase<function (type parameter) S in DefineStoreOptionsBase<S, Store>
S, function (type parameter) Store in DefineStoreOptionsBase<S, Store>
Store> {
DefineStoreOptionsBase<S, Store>.share?: PluginOptions<S> | undefined
share?: type PluginOptions<T extends StateTree> = {
enable?: boolean;
initialize?: boolean;
serializer?: Serializer<T>;
}
PluginOptions<function (type parameter) S in DefineStoreOptionsBase<S, Store>
S>
}
}
export function function PiniaSharedState<T extends StateTree>({ enable, initialize, serializer, }?: PluginOptions<T>): ({ store, options }: PiniaPluginContext) => void
PiniaSharedState<function (type parameter) T in PiniaSharedState<T extends StateTree>({ enable, initialize, serializer, }?: PluginOptions<T>): ({ store, options }: PiniaPluginContext) => void
T extends import StateTree
StateTree>({
enable: boolean
enable = false,
initialize: boolean
initialize = false,
serializer: Serializer<T>
serializer = {
serialize: (value: T) => string
serialize: var JSON: JSON
An intrinsic object that provides functions to convert JavaScript values to and from the JavaScript Object Notation (JSON) format.JSON.JSON.stringify(value: any, replacer?: (this: any, key: string, value: any) => any, space?: string | number): string (+1 overload)
Converts a JavaScript value to a JavaScript Object Notation (JSON) string.stringify,
deserialize: (value: string) => T
deserialize: var JSON: JSON
An intrinsic object that provides functions to convert JavaScript values to and from the JavaScript Object Notation (JSON) format.JSON.JSON.parse(text: string, reviver?: (this: any, key: string, value: any) => any): any
Converts a JavaScript Object Notation (JSON) string into an object.parse,
},
}: type PluginOptions<T extends StateTree> = {
enable?: boolean;
initialize?: boolean;
serializer?: Serializer<T>;
}
PluginOptions<function (type parameter) T in PiniaSharedState<T extends StateTree>({ enable, initialize, serializer, }?: PluginOptions<T>): ({ store, options }: PiniaPluginContext) => void
T> = {}) {
return ({ store: PiniaPluginContext
store, options: PiniaPluginContext
options }: import PiniaPluginContext
PiniaPluginContext) => {
if (!(options: PiniaPluginContext
options.share?.enable ?? enable: boolean
enable)) return
const const channel: BroadcastChannel
channel = new var BroadcastChannel: new (name: string) => BroadcastChannel
[MDN Reference](https://developer.mozilla.org/docs/Web/API/BroadcastChannel)
`BroadcastChannel` class is a global reference for `import { BroadcastChannel } from 'worker_threads'`
https://nodejs.org/api/globals.html#broadcastchannelBroadcastChannel(store: PiniaPluginContext
store.$id)
let let timestamp: number
timestamp = 0
let let externalUpdate: boolean
externalUpdate = false
// Initial state sync
if (options: PiniaPluginContext
options.share?.initialize ?? initialize: boolean
initialize) {
const channel: BroadcastChannel
channel.BroadcastChannel.postMessage(message: any): void
Sends the given message to other BroadcastChannel objects set up for this channel. Messages can be structured objects, e.g. nested objects and arrays.
[MDN Reference](https://developer.mozilla.org/docs/Web/API/BroadcastChannel/postMessage)postMessage({ type: string
type: 'SYNC_REQUEST' })
}
// State change listener
store: PiniaPluginContext
store.$subscribe((_mutation: any
_mutation, state: any
state) => {
if (let externalUpdate: boolean
externalUpdate) return
let timestamp: number
timestamp = var Date: DateConstructor
Enables basic storage and retrieval of dates and times.Date.DateConstructor.now(): number
Returns the number of milliseconds elapsed since midnight, January 1, 1970 Universal Coordinated Time (UTC).now()
const channel: BroadcastChannel
channel.BroadcastChannel.postMessage(message: any): void
Sends the given message to other BroadcastChannel objects set up for this channel. Messages can be structured objects, e.g. nested objects and arrays.
[MDN Reference](https://developer.mozilla.org/docs/Web/API/BroadcastChannel/postMessage)postMessage({
type: string
type: 'STATE_UPDATE',
timestamp: number
timestamp,
state: string
state: serializer: Serializer<T>
serializer.serialize: (value: T) => string
serialize(state: any
state as function (type parameter) T in PiniaSharedState<T extends StateTree>({ enable, initialize, serializer, }?: PluginOptions<T>): ({ store, options }: PiniaPluginContext) => void
T),
})
})
// Message handler
const channel: BroadcastChannel
channel.BroadcastChannel.onmessage: ((this: BroadcastChannel, ev: MessageEvent) => any) | null
[MDN Reference](https://developer.mozilla.org/docs/Web/API/BroadcastChannel/message_event)onmessage = (event: MessageEvent<BroadcastMessage>
event: interface MessageEvent<T = any>
A message received by a target object.
[MDN Reference](https://developer.mozilla.org/docs/Web/API/MessageEvent)MessageEvent<BroadcastMessage>) => {
const const data: BroadcastMessage
data = event: MessageEvent<BroadcastMessage>
event.MessageEvent<BroadcastMessage>.data: BroadcastMessage
Returns the data of the message.
[MDN Reference](https://developer.mozilla.org/docs/Web/API/MessageEvent/data)data
if (
const data: BroadcastMessage
data.BroadcastMessage.type: "STATE_UPDATE" | "SYNC_REQUEST"
type === 'STATE_UPDATE' &&
const data: BroadcastMessage
data.BroadcastMessage.timestamp?: number | undefined
timestamp &&
const data: BroadcastMessage
data.BroadcastMessage.timestamp?: number
timestamp > let timestamp: number
timestamp &&
const data: BroadcastMessage
data.BroadcastMessage.state?: string | undefined
state
) {
let externalUpdate: boolean
externalUpdate = true
let timestamp: number
timestamp = const data: BroadcastMessage
data.BroadcastMessage.timestamp?: number
timestamp
store: PiniaPluginContext
store.$patch(serializer: Serializer<T>
serializer.deserialize: (value: string) => T
deserialize(const data: BroadcastMessage
data.BroadcastMessage.state?: string
state))
let externalUpdate: boolean
externalUpdate = false
}
if (const data: BroadcastMessage
data.BroadcastMessage.type: "STATE_UPDATE" | "SYNC_REQUEST"
type === 'SYNC_REQUEST') {
const channel: BroadcastChannel
channel.BroadcastChannel.postMessage(message: any): void
Sends the given message to other BroadcastChannel objects set up for this channel. Messages can be structured objects, e.g. nested objects and arrays.
[MDN Reference](https://developer.mozilla.org/docs/Web/API/BroadcastChannel/postMessage)postMessage({
type: string
type: 'STATE_UPDATE',
timestamp: number
timestamp,
state: string
state: serializer: Serializer<T>
serializer.serialize: (value: T) => string
serialize(store: PiniaPluginContext
store.$state as function (type parameter) T in PiniaSharedState<T extends StateTree>({ enable, initialize, serializer, }?: PluginOptions<T>): ({ store, options }: PiniaPluginContext) => void
T),
})
}
}
}
}
The plugin works by:
- Creating a BroadcastChannel for each store
- Subscribing to store changes and broadcasting updates
- Handling incoming messages from other tabs
- Using timestamps to prevent update cycles
- Supporting custom serialization for complex state
Communication Flow Diagram
Using the Synchronized Store
Components can use the synchronized store just like any other Pinia store:
import { import useCounterStore
useCounterStore } from './stores/counter'
const const counterStore: any
counterStore = import useCounterStore
useCounterStore()
// State changes will automatically sync across tabs
const counterStore: any
counterStore.increment()
Conclusion
With this Pinia plugin, we’ve added cross-tab state synchronization with minimal configuration. The solution is lightweight, type-safe, and leverages the built-in BroadcastChannel API. This pattern is particularly useful for applications where users frequently work across multiple tabs and need a consistent state experience.
Remember to consider the following when using this plugin:
- Only enable sharing for stores that truly need it
- Be mindful of performance with large state objects
- Consider custom serialization for complex data structures
- Test thoroughly across different browser scenarios
Future Optimization: Web Workers
For applications with heavy cross-tab communication or complex state transformations, consider offloading the BroadcastChannel handling to a Web Worker. This approach can improve performance by:
- Moving message processing off the main thread
- Handling complex state transformations without blocking UI
- Reducing main thread load when syncing large state objects
- Buffering and batching state updates for better performance
This is particularly beneficial when:
- Your application has many tabs open simultaneously
- State updates are frequent or computationally intensive
- You need to perform validation or transformation on synced data
- The application handles large datasets that need to be synced
You can find the complete code for this plugin in the GitHub repository. It also has examples of how to use it with Web Workers.