Syncing a Pinia store with WebSockets #
Your Vue 3 app keeps orders in a Pinia store and receives order updates over a WebSocket. The first implementation opens the socket in a component’s onMounted, pushes messages into the store, and closes it in onUnmounted. Then the order list and the order detail page both need live data, so both components open sockets and the store receives every update twice. Hot module replacement during development leaves orphan connections that keep writing into a store instance that no longer exists. And after a laptop sleeps, the store silently holds data that is ten minutes old. A real-time Pinia store works best when the store owns the connection: components ask for data, the store decides when to connect, and every update passes through one reducer that knows how to merge it.
Root cause #
Pinia stores are singletons per app instance, while components come and go. Tying a socket to a component makes the connection’s lifetime depend on which views happen to be mounted, which produces both duplicate connections (two components, two sockets) and gaps (no component mounted, no socket, missed updates that nothing refetches). Pushing raw messages into the store with store.orders.push(msg) adds a second problem: there is no single place that checks ordering, deduplicates, or knows what to do after a reconnect.
Pinia’s reactivity makes these bugs quiet. Every write to reactive state triggers updates, so a duplicated or stale message looks exactly like a valid one on screen.
Resolution #
The setup store below keeps orders normalized by id, applies every server message through applyEvent with a version check, and reference-counts interest: the first acquire() connects, the last release() disconnects after a short grace period so quick navigations do not churn the connection. On every open — first connect or reconnect — it asks the server for a snapshot, so a sleep or network drop never leaves stale data behind.
// stores/orders.ts
import { defineStore, acceptHMRUpdate } from 'pinia';
import { computed, ref, shallowRef } from 'vue';
export interface Order { id: string; status: 'open' | 'paid' | 'shipped'; total: number; version: number }
type ServerEvent =
| { type: 'orders.snapshot'; orders: Order[] }
| { type: 'order.updated'; order: Order }
| { type: 'order.deleted'; id: string; version: number };
const RELEASE_GRACE_MS = 5_000; // keep the socket briefly after the last consumer leaves
const RECONNECT_BASE_MS = 500;
const RECONNECT_CAP_MS = 15_000;
export const useOrdersStore = defineStore('orders', () => {
const byId = ref<Record<string, Order>>({});
const status = ref<'idle' | 'connecting' | 'live' | 'offline'>('idle');
const socket = shallowRef<WebSocket | null>(null); // not deep-reactive: it's a handle
let consumers = 0;
let releaseTimer: ReturnType<typeof setTimeout> | undefined;
let attempt = 0;
const orders = computed(() => Object.values(byId.value).sort((a, b) => a.id.localeCompare(b.id)));
function applyEvent(ev: ServerEvent) {
if (ev.type === 'orders.snapshot') {
byId.value = Object.fromEntries(ev.orders.map((o) => [o.id, o])); // authoritative reset
} else if (ev.type === 'order.updated') {
const cur = byId.value[ev.order.id];
if (!cur || ev.order.version > cur.version) byId.value[ev.order.id] = ev.order;
} else if (ev.type === 'order.deleted') {
const cur = byId.value[ev.id];
if (cur && ev.version >= cur.version) delete byId.value[ev.id];
}
}
function connect() {
status.value = 'connecting';
const ws = new WebSocket(import.meta.env.VITE_WS_URL + '/orders');
socket.value = ws;
ws.onopen = () => {
attempt = 0;
status.value = 'live';
ws.send(JSON.stringify({ type: 'orders.subscribe' })); // server replies with a snapshot
};
ws.onmessage = (e) => applyEvent(JSON.parse(e.data));
ws.onclose = () => {
socket.value = null;
if (consumers === 0) { status.value = 'idle'; return; } // closed on purpose
status.value = 'offline';
const cap = Math.min(RECONNECT_CAP_MS, RECONNECT_BASE_MS * 2 ** attempt++);
setTimeout(() => { if (consumers > 0 && !socket.value) connect(); }, Math.random() * cap);
};
}
function acquire() {
consumers += 1;
clearTimeout(releaseTimer);
if (!socket.value) connect();
}
function release() {
consumers = Math.max(0, consumers - 1);
if (consumers > 0) return;
releaseTimer = setTimeout(() => socket.value?.close(1000, 'no consumers'), RELEASE_GRACE_MS);
}
return { byId, orders, status, acquire, release, applyEvent };
});
// HMR: swap store logic without leaking the old instance's socket.
if (import.meta.hot) import.meta.hot.accept(acceptHMRUpdate(useOrdersStore, import.meta.hot));
Components then express interest with a tiny composable, so no view ever touches the socket:
// composables/useLiveOrders.ts
import { onMounted, onUnmounted } from 'vue';
import { storeToRefs } from 'pinia';
import { useOrdersStore } from '@/stores/orders';
export function useLiveOrders() {
const store = useOrdersStore();
onMounted(store.acquire);
onUnmounted(store.release);
return storeToRefs(store); // orders, status, byId as refs
}
Normalizing by id is what makes patches cheap and safe: an update replaces one key, Vue’s reactivity re-renders only what reads that order, and the version check discards replays and out-of-order deliveries, the problem covered in handling out-of-order WebSocket messages. Keeping the socket in a shallowRef avoids Vue wrapping a browser object in a deep reactive proxy, which is both wasteful and occasionally breaks identity checks.
Edge cases #
Server-side rendering. In Nuxt or any SSR setup, the store is created on the server during rendering. acquire is called from onMounted, which never runs on the server, so no socket is opened there; keep it that way and never call connect from the store’s setup body. See Nuxt 3 WebSocket integration.
Large collections. Replacing the whole byId object on a snapshot re-renders everything that reads the store, which is right for a resync but expensive to do frequently. For very large maps, consider shallowRef for byId and replacing individual keys via triggerRef.
Optimistic writes. If actions mutate orders optimistically before the server confirms, store the optimistic change separately from confirmed data, as in optimistic UI rollback on WebSocket nack, so a server event does not wipe out a pending edit.
Verification #
Open Vue DevTools’ Pinia panel and the Network panel’s WS view together. Navigate between the list and the detail page: exactly one WebSocket connection should exist the whole time. Leave all live views for longer than the grace period and confirm the socket closes with code 1000. Use DevTools’ offline mode for a few seconds, change orders from another client, go back online, and confirm the store’s state matches the server after the snapshot arrives. During development, edit the store file and confirm no second connection appears after HMR.
Operational checklist #
FAQ #
Should the WebSocket live in a Pinia store or a composable? #
Put it in the store when the socket feeds one domain of shared state that several components read. A standalone composable, as in a Vue 3 useWebSocket composable with auto-reconnect, suits connections used by one component or a small feature.
Why use shallowRef for the socket? #
A WebSocket is a browser handle, not data. Making it deeply reactive wraps it in a proxy for no benefit, and some code comparing socket identity will see the proxy rather than the original object.
How do I avoid re-rendering the whole list on each update? #
Normalize by id and update a single key; components that read other orders do not re-render. Render lists with a stable key per order so Vue patches only the changed row.
What about Pinia’s $subscribe for sending changes? #
$subscribe fires on any state change, including changes that came from the server, so using it to send updates creates echo loops. Send changes from explicit actions instead.
Related #
- Vue 3 useWebSocket Composable with Auto-Reconnect — a component-level alternative.
- Vue 3 WebSocket Plugin with provide/inject — app-wide connections.
- Vue 3 Real-Time Dashboard Best Practices — rendering the store efficiently.
- Reconciling Snapshots and Deltas over WebSockets — the resync-on-open pattern in depth.
Back to Vue 3 Composables for Real-Time.