Destino Dev

Blog

Sincronizando estado entre abas com Pinia e BroadcastChannel (Composition API)

Por John Pires · 12 de março de 2026

Sincronizando estado entre abas com Pinia e BroadcastChannel (Composition API)

1) Por que aprender isso em 2026

Contexto atual

Aplicações web modernas são frequentemente executadas em várias abas ou janelas do mesmo navegador. Um usuário pode abrir sua aplicação em duas abas e esperar que ações feitas em uma aba reflitam instantaneamente na outra — por exemplo, um contador, preferências de tema, carrinho de compras, ou estado de autenticação. Em 2026 isso continua relevante: PWAs, aplicações offline e sessões longas fazem com que sincronizar estado local entre abas seja necessário para boa experiência.

Por que BroadcastChannel + Pinia?

Pinia é a escolha padrão para state management no ecossistema Vue 3. Ele é leve e funciona bem com Composition API. O BroadcastChannel é uma API nativa do browser que permite comunicação simples e eficiente entre contextos (abas, iframes, workers) no mesmo origin. Combinar Pinia com BroadcastChannel permite propagar mudanças de estado sem depender de servidores ou polling.

Analogia simples: imagine Pinia como o quadro branco compartilhado da aba atual — você escreve nele. BroadcastChannel é o mensageiro que leva uma cópia da sua escrita para os quadros de outras abas. Cada aba recebe a mensagem e atualiza seu quadro local.

2) Configuracao do ambiente

Requisitos mínimos

Você precisa de Node 16+, npm ou yarn, e de um projeto Vue 3 configurado com Vite (recomendado). Vamos listar dependências essenciais e comandos para criar um projeto mínimo.

  1. Node.js instalado
  2. Vite + Vue 3
  3. Pinia

Comandos rápidos para criar e preparar o projeto:

npm create vite@latest my-app --template vue
cd my-app
npm install
npm install pinia
npm run dev

O comando acima cria um esqueleto Vite + Vue 3. Em seguida instalamos Pinia. O BroadcastChannel já faz parte do browser, não precisa instalar nada.

Estrutura mínima para o exemplo

Vamos usar Composition API com <script setup>. No arquivo main.js/ts precisamos instalar Pinia.

// src/main.js
import { createApp } from 'vue'
import { createPinia } from 'pinia'
import App from './App.vue'

const app = createApp(App)
const pinia = createPinia()
app.use(pinia)
app.mount('#app')

Esse código inicializa o Pinia e injeta no app. Tudo pronto para criar stores e componentes.

3) Hello World progressivo

Objetivo do passo a passo

Vamos construir um contador que sincroniza entre abas. Primeiro: store Pinia com um contador que persiste localmente (localStorage) e depois broadcast com BroadcastChannel.

Passo 1 — store Pinia básico

Crie um store simples que exponha o contador e métodos para incrementá-lo. Contexto: este store roda localmente na aba.

// src/stores/counter.js
import { defineStore } from 'pinia'
import { ref } from 'vue'

export const useCounterStore = defineStore('counter', () => {
  const count = ref(Number(localStorage.getItem('count') || 0))

  function increment() {
    count.value++
    localStorage.setItem('count', String(count.value))
  }

  return { count, increment }
})

O que esse bloco faz: cria um store com estado reativo e persiste em localStorage toda vez que incrementamos. Depois de rodar, o contador sobrevive a refresh.

Depois: vamos usar esse store no componente e validar que a aba atual atualiza.

<!-- src/components/CounterView.vue -->
<template>
  <div>
    <h2>Contador sincronizado (local only)</h2>
    <p>Valor: {{ counter.count }}</p>
    <button @click="counter.increment">Incrementar</button>
  </div>
</template>

<script setup>
import { useCounterStore } from '@/stores/counter'
const counter = useCounterStore()
</script>

Ao abrir duas abas, cada uma terá seu próprio store mas ambas lêem localStorage no início — o contador não dispara atualizações entre abas automaticamente.

Passo 2 — introduzindo BroadcastChannel

Agora vamos adicionar um canal para propagar eventos de incremento. O objetivo: quando uma aba incrementa, ela publica uma mensagem; as outras abas recebem e aplicam a atualização localmente.

// src/composables/useBroadcast.js
export function createBroadcastChannel(name = 'app-channel') {
  if (typeof BroadcastChannel === 'undefined') {
    // fallback simples: eventos no storage (menos eficiente)
    return {
      channel: null,
      postMessage: (msg) => localStorage.setItem('broadcast:' + name, JSON.stringify({ msg, t: Date.now() })),
      onMessage: (handler) => window.addEventListener('storage', (e) => {
        if (e.key === 'broadcast:' + name) handler(JSON.parse(e.newValue).msg)
      })
    }
  }

  const bc = new BroadcastChannel(name)
  return {
    channel: bc,
    postMessage: (msg) => bc.postMessage(msg),
    onMessage: (handler) => bc.addEventListener('message', (ev) => handler(ev.data)),
    close: () => bc.close()
  }
}

O que acontece: criamos um wrapper que usa BroadcastChannel quando disponível e cai para um fallback usando localStorage+evento storage quando não há suporte. Isso melhora compatibilidade para navegadores mais antigos.

Agora integramos o broadcast ao store.

// src/stores/counter.js (atualizado)
import { defineStore } from 'pinia'
import { ref } from 'vue'
import { createBroadcastChannel } from '@/composables/useBroadcast'

export const useCounterStore = defineStore('counter', () => {
  const count = ref(Number(localStorage.getItem('count') || 0))
  const bc = createBroadcastChannel('counter')

  function increment() {
    count.value++
    localStorage.setItem('count', String(count.value))
    // publica para outras abas
    bc.postMessage({ type: 'increment', value: count.value })
  }

  // escuta mensagens externas
  bc.onMessage((msg) => {
    if (msg && msg.type === 'increment') {
      // evita loop: atualiza localmente sem re-publicar
      count.value = msg.value
      localStorage.setItem('count', String(count.value))
    }
  })

  return { count, increment }
})

Após esse passo, ao clicar em uma aba, as outras abas recebem a mensagem e atualizam o contador local automaticamente.

4) Mini-projeto prático

Descrição do mini-projeto

Vamos montar um mini-projeto: um painel de preferências (tema claro/escuro e idioma) que sincroniza entre abas. O projeto usa:

  • Pinia para gerenciar estado
  • BroadcastChannel para sincronizar
  • Persistência local via localStorage

O objetivo é mostrar como propagar updates mais complexos (objetos) e como tratar conflitos simples.

Store de preferências (exemplo real)

Crie um store chamado prefs. O store deve expor estado, actions e um método para aplicar payloads recebidos do canal.

// src/stores/prefs.js
import { defineStore } from 'pinia'
import { reactive } from 'vue'
import { createBroadcastChannel } from '@/composables/useBroadcast'

export const usePrefsStore = defineStore('prefs', () => {
  const defaultPrefs = { theme: 'light', lang: 'pt' }
  const prefs = reactive(JSON.parse(localStorage.getItem('prefs') || JSON.stringify(defaultPrefs)))
  const bc = createBroadcastChannel('prefs')

  function setTheme(theme) {
    prefs.theme = theme
    persistAndBroadcast()
  }

  function setLang(lang) {
    prefs.lang = lang
    persistAndBroadcast()
  }

  function applyRemote(newPrefs) {
    // estratégia simples: sobrescrever campos existentes
    Object.assign(prefs, newPrefs)
    localStorage.setItem('prefs', JSON.stringify(prefs))
  }

  function persistAndBroadcast() {
    localStorage.setItem('prefs', JSON.stringify(prefs))
    bc.postMessage({ type: 'prefs:update', payload: prefs })
  }

  bc.onMessage((msg) => {
    if (!msg) return
    if (msg.type === 'prefs:update') applyRemote(msg.payload)
  })

  return { prefs, setTheme, setLang }
})

O que esse código faz: guarda preferências, persiste no localStorage toda vez que há mudança e envia o estado completo para outras abas. Ao receber uma mensagem, aplica as mudanças localmente. Estratégia simples, mas funciona para a maioria dos casos de preferência do usuário.

Componente de preferências

Crie um componente com opções de tema e idioma. Ao alterar, as outras abas atualizam automaticamente.

<!-- src/components/PrefsPanel.vue -->
<template>
  <div>
    <h3>Preferências</h3>
    <label>Tema:
      <select v-model="prefs.theme" @change="setTheme(prefs.theme)">
        <option value="light">Claro</option>
        <option value="dark">Escuro</option>
      </select>
    </label>

    <label>Idioma:
      <select v-model="prefs.lang" @change="setLang(prefs.lang)">
        <option value="pt">Português</option>
        <option value="en">English</option>
      </select>
    </label>
  </div>
</template>

<script setup>
import { usePrefsStore } from '@/stores/prefs'
const store = usePrefsStore()
const { prefs, setTheme, setLang } = store
</script>

Agora abra duas abas, altere o tema em uma e veja a outra atualizando. Se quiser, adicione debounce antes de persistir para evitar flood de mensagens.

Trabalhando com conflitos

Conflitos aparecem quando duas abas alteram o mesmo valor quase simultaneamente. Estratégias simples:

  • Último a escrever vence — enviar timestamp junto e aplicar apenas se timestamp for mais recente.
  • Merge por propriedade — combinar objetos e não sobrescrever campos que não foram alterados.
  • Locking leve — bloquear ação local por curto período (risky para UX).

Exemplo: incluir timestamp nas mensagens é geralmente suficiente para preferências de usuário. Para dados críticos (transações financeiras), prefira backend centralizado.

5) Recursos para continuar estudando

Documentação e leitura

Links e recursos úteis para aprofundar:

Próximos passos práticos

Sugestões para praticar:

  1. Adicione debounce e throttling às mensagens de broadcast.
  2. Implemente fallback robusto para navegadores que não suportam BroadcastChannel.
  3. Experimente sincronizar listas (ex: tarefas) e resolver conflitos por operação (CRDTs simples).
Tip: broadcast curto e objetivo — envie mensagens com apenas o necessário (tipo e payload mínimo). Evite broadcast de objetos muito grandes sem compressão.

Erros comuns, armadilhas e dicas práticas

Erros comuns

1) Loop infinito: publicar uma mudança quando você recebe a mesma mudança. Sempre distinguir origem ou não republicar mensagens recebidas.

2) Confiar apenas em localStorage: storage event não é disparado na aba que escreve. Além disso, localStorage não é transacional.

3) Não lidar com versão do payload: mudar shape do estado sem versionamento pode quebrar abas abertas com código antigo.

Dicas práticas

1) Adicione um campo originId ou clientId na mensagem para evitar loops: a aba que publicou ignora eventos com seu próprio ID.

2) Envie timestamp e, se necessário, um sequenceNumber para resolver ordem das operações.

3) Faça testes manuais: abra múltiplas abas e redesenhe cenários com mudanças rápidas para verificar comportamento.

Conclusão prática

Sincronizar estado entre abas com Pinia e BroadcastChannel é uma solução leve, eficiente e fácil de implementar. Pinia gerencia o estado reativo da aba, enquanto BroadcastChannel propaga mudanças para outras abas sem necessidade de servidor.

Em resumo:

  • Use Pinia para estado local e persistência simples.
  • Use BroadcastChannel para enviar atualizações entre abas.
  • Previna loops, trate conflitos com timestamps e inclua fallback para compatibilidade.

Pratique com o contador e o painel de preferências acima. Em seguida, avance para sincronizar coleções e investigar CRDTs se precisar resolver conflitos complexos em tempo real.

Implementando essas técnicas você melhora a experiência do usuário sem adicionar dependências pesadas ou arquitetura complicada. Boa prática: comece simples, meça comportamentos e só então aumente a complexidade da sincronização.

Assuntos: Vue.js, pinia, broadcastchannel, composition-api, state-management

Leia também