Skip to content

Building a Pinia Plugin for Cross-Tab State Syncing

Published: at 

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:

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:

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:

  1. Messages are sent using the postMessage() method
  2. Messages are received through the onmessage event handler
  3. 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.
@paramvalue - The object to wrap in the ref.@see{@link https://vuejs.org/api/reactivity-core.html#ref}
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 defineStoredefineStore } from 'pinia' export const const useCounterStore: anyuseCounterStore = import defineStoredefineStore( '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.
@paramvalue - The object to wrap in the ref.@see{@link https://vuejs.org/api/reactivity-core.html#ref}
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.
@example```js // Creating a readonly computed ref: const count = ref(1) const plusOne = computed(() => count.value + 1) console.log(plusOne.value) // 2 plusOne.value++ // error ``` ```js // Creating a writable computed ref: const count = ref(1) const plusOne = computed({ get: () => count.value + 1, set: (val) => { count.value = val - 1 } }) plusOne.value = 1 console.log(count.value) // 0 ```@paramgetter - Function that produces the next value.@paramdebugOptions - For debugging. See {@link https://vuejs.org/guide/extras/reactivity-in-depth.html#computed-debugging}.@see{@link https://vuejs.org/api/reactivity-core.html#computed}
computed
(() => const count: Ref<number, number>count.Ref<number, number>.value: numbervalue * 2)
function function (local function) increment(): voidincrement() { const count: Ref<number, number>count.Ref<number, number>.value: numbervalue++ } return { count: Ref<number, number>count, doubleCount: ComputedRef<number>doubleCount, increment: () => voidincrement } }, {
share: {
    enable: boolean;
    initialize: boolean;
}
share
: {
enable: booleanenable: true, initialize: booleaninitialize: 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 createPiniacreatePinia } from 'pinia'
import { import PiniaSharedStatePiniaSharedState } from './plugin/plugin'

const const pinia: anypinia = import createPiniacreatePinia()
const pinia: anypinia.use(import PiniaSharedStatePiniaSharedState)

Plugin Implementation plugin/plugin.ts

Here’s our complete plugin implementation with TypeScript support:

import type { import PiniaPluginContextPiniaPluginContext, import StateTreeStateTree, import DefineStoreOptionsDefineStoreOptions } 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 StateTreeStateTree> = {
serialize: (value: T) => stringserialize: (value: T extends StateTreevalue: function (type parameter) T in type Serializer<T extends StateTree>T) => string deserialize: (value: string) => Tdeserialize: (value: stringvalue: 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 | undefinedtimestamp?: number BroadcastMessage.state?: string | undefinedstate?: 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 StateTreeStateTree> = {
enable?: boolean | undefinedenable?: boolean initialize?: boolean | undefinedinitialize?: boolean serializer?: Serializer<T> | undefinedserializer?:
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 StateTreeStateTree = import StateTreeStateTree, 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 DefineStoreOptionsDefineStoreOptions<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> | undefinedshare?:
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> | undefinedshare?:
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) => voidPiniaSharedState<function (type parameter) T in PiniaSharedState<T extends StateTree>({ enable, initialize, serializer, }?: PluginOptions<T>): ({ store, options }: PiniaPluginContext) => voidT extends import StateTreeStateTree>({ enable: booleanenable = false, initialize: booleaninitialize = false, serializer: Serializer<T>serializer = { serialize: (value: T) => stringserialize: 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.
@paramvalue A JavaScript value, usually an object or array, to be converted.@paramreplacer A function that transforms the results.@paramspace Adds indentation, white space, and line break characters to the return-value JSON text to make it easier to read.
stringify
,
deserialize: (value: string) => Tdeserialize: 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.
@paramtext A valid JSON string.@paramreviver A function that transforms the results. This function is called for each member of the object. If a member contains nested objects, the nested objects are transformed before the parent object is.
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) => voidT> = {}) {
return ({ store: PiniaPluginContextstore, options: PiniaPluginContextoptions }: import PiniaPluginContextPiniaPluginContext) => { if (!(options: PiniaPluginContextoptions.share?.enable ?? enable: booleanenable)) return const const channel: BroadcastChannelchannel = 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#broadcastchannel
@sincev18.0.0
BroadcastChannel
(store: PiniaPluginContextstore.$id)
let let timestamp: numbertimestamp = 0 let let externalUpdate: booleanexternalUpdate = false // Initial state sync if (options: PiniaPluginContextoptions.share?.initialize ?? initialize: booleaninitialize) { const channel: BroadcastChannelchannel.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: stringtype: 'SYNC_REQUEST' })
} // State change listener store: PiniaPluginContextstore.$subscribe((_mutation: any_mutation, state: anystate) => { if (let externalUpdate: booleanexternalUpdate) return let timestamp: numbertimestamp = 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: BroadcastChannelchannel.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: stringtype: 'STATE_UPDATE', timestamp: numbertimestamp, state: stringstate: serializer: Serializer<T>serializer.serialize: (value: T) => stringserialize(state: anystate as function (type parameter) T in PiniaSharedState<T extends StateTree>({ enable, initialize, serializer, }?: PluginOptions<T>): ({ store, options }: PiniaPluginContext) => voidT), }) }) // Message handler const channel: BroadcastChannelchannel.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)
@sincev15.0.0
MessageEvent
<BroadcastMessage>) => {
const const data: BroadcastMessagedata = 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: BroadcastMessagedata.BroadcastMessage.type: "STATE_UPDATE" | "SYNC_REQUEST"type === 'STATE_UPDATE' && const data: BroadcastMessagedata.BroadcastMessage.timestamp?: number | undefinedtimestamp && const data: BroadcastMessagedata.BroadcastMessage.timestamp?: numbertimestamp > let timestamp: numbertimestamp && const data: BroadcastMessagedata.BroadcastMessage.state?: string | undefinedstate ) { let externalUpdate: booleanexternalUpdate = true let timestamp: numbertimestamp = const data: BroadcastMessagedata.BroadcastMessage.timestamp?: numbertimestamp store: PiniaPluginContextstore.$patch(serializer: Serializer<T>serializer.deserialize: (value: string) => Tdeserialize(const data: BroadcastMessagedata.BroadcastMessage.state?: stringstate)) let externalUpdate: booleanexternalUpdate = false } if (const data: BroadcastMessagedata.BroadcastMessage.type: "STATE_UPDATE" | "SYNC_REQUEST"type === 'SYNC_REQUEST') { const channel: BroadcastChannelchannel.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: stringtype: 'STATE_UPDATE', timestamp: numbertimestamp, state: stringstate: serializer: Serializer<T>serializer.serialize: (value: T) => stringserialize(store: PiniaPluginContextstore.$state as function (type parameter) T in PiniaSharedState<T extends StateTree>({ enable, initialize, serializer, }?: PluginOptions<T>): ({ store, options }: PiniaPluginContext) => voidT), }) } } } }

The plugin works by:

  1. Creating a BroadcastChannel for each store
  2. Subscribing to store changes and broadcasting updates
  3. Handling incoming messages from other tabs
  4. Using timestamps to prevent update cycles
  5. Supporting custom serialization for complex state

Communication Flow Diagram

User interacts with store in Tab 1

Store state changes

Plugin detects change

BroadcastChannel posts STATE_UPDATE

Other tabs receive STATE_UPDATE

Plugin patches store state in Tab 2

Using the Synchronized Store

Components can use the synchronized store just like any other Pinia store:

import { import useCounterStoreuseCounterStore } from './stores/counter'

const const counterStore: anycounterStore = import useCounterStoreuseCounterStore()
// State changes will automatically sync across tabs
const counterStore: anycounterStore.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:

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:

This is particularly beneficial when:

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.

Questions or thoughts?

Follow me on X for more TypeScript, Vue, and web dev insights! Feel free to DM me with:

  • Questions about this article
  • Topic suggestions
  • Feedback or improvements
Connect on X

Related Posts