Syncing SSE Events into Pinia Stores Permalink to this section
Part of State-Management Integration for SSE, under Frontend Consumption & Client Patterns.
Pinia stores outlive components, which makes them the right home for a stream β and the wrong place to open one carelessly, because a connection created in a store lives until the page unloads whether anyone is watching or not.
Symptom & Developer Intent Permalink to this section
- Navigating between routes opens a new stream each time and never closes the old one.
- Streamed data updates in the store but the view does not re-render.
- Vue devtools shows thousands of reactive proxies after a busy session.
- Reconnects replay events and counters double-count.
- Two components each start the stream, so every event is applied twice.
The intent is one connection owned by the store layer, reactive updates that are cheap, and idempotent application of replayed events.
Root Cause Analysis Permalink to this section
Three distinct causes sit behind those symptoms.
Ownership. A component that starts a stream can close it on unmount; a store cannot, because it has no lifecycle. Without an explicit reference count, the connection either leaks on every route change or is closed by the first component to leave while others are still watching.
Reactivity cost. Pinia state is deeply reactive by default, so every streamed payload is wrapped in a proxy on assignment β recursively. For append-only stream data nothing reads those inner keys reactively, so the proxy work is pure overhead, and at a few hundred events a second it is visible.
Idempotence. Reconnect replay is inclusive at the boundary: the server resends the event matching the cursor. A reducer that increments rather than sets will double-count it, and the drift is permanent.
A fourth, subtler issue is reassignment. Replacing a reactive() object breaks the binding that components hold, so the view silently stops updating β the Vue-specific version of βthe data is there but nothing rendersβ.
Step-by-Step Resolution Permalink to this section
Step 1 β Put the connection in its own store with reference counting Permalink to this section
// stores/stream.js
import { defineStore } from 'pinia';
import { ref, shallowRef } from 'vue';
export const useStreamStore = defineStore('stream', () => {
const status = ref('idle'); // idle | connecting | live | retrying | offline
const lastEventId = ref(null);
const es = shallowRef(null); // shallowRef: never proxy the EventSource itself
let subscribers = 0;
function connect() {
subscribers += 1;
if (es.value) return release; // already connected: just count the subscriber
status.value = 'connecting';
const url = new URL('/api/stream', location.origin);
if (lastEventId.value) url.searchParams.set('lastEventId', lastEventId.value);
const source = new EventSource(url, { withCredentials: true });
source.addEventListener('open', () => { status.value = 'live'; });
source.addEventListener('error', () => {
status.value = source.readyState === EventSource.CLOSED ? 'offline' : 'retrying';
});
source.addEventListener('resync', () => { useOrdersStore().resync(); });
// Route domain events to the stores that own them.
source.addEventListener('order', (e) => {
lastEventId.value = e.lastEventId || lastEventId.value;
useOrdersStore().applyEvent(JSON.parse(e.data), e.lastEventId);
});
es.value = source;
return release;
}
function release() {
subscribers = Math.max(0, subscribers - 1);
if (subscribers === 0) { // last component out closes the stream
es.value?.close();
es.value = null;
status.value = 'idle';
}
}
return { status, lastEventId, connect, release };
});
Step 2 β Connect and release from the component lifecycle Permalink to this section
<script setup>
import { onMounted, onUnmounted } from 'vue';
import { useStreamStore } from '@/stores/stream';
const stream = useStreamStore();
let release;
onMounted(() => { release = stream.connect(); });
onUnmounted(() => { release?.(); }); // reference count, not an unconditional close
</script>
Step 3 β Keep streamed payloads out of deep reactivity Permalink to this section
// stores/orders.js
import { defineStore } from 'pinia';
import { shallowRef, triggerRef } from 'vue';
export const useOrdersStore = defineStore('orders', () => {
// A Map in a shallowRef: no per-payload proxy, one explicit trigger per batch.
const byId = shallowRef(new Map());
const seen = new Set(); // event ids already applied
function applyEvent(order, eventId) {
if (eventId && seen.has(eventId)) return; // replayed event: drop it
if (eventId) {
seen.add(eventId);
if (seen.size > 5000) seen.delete(seen.values().next().value); // bounded
}
byId.value.set(order.id, order); // mutate the Mapβ¦
triggerRef(byId); // β¦then tell Vue once
}
async function resync() {
const snapshot = await (await fetch('/api/orders')).json();
byId.value = new Map(snapshot.map((o) => [o.id, o]));
seen.clear();
triggerRef(byId);
}
return { byId, applyEvent, resync };
});
Step 4 β Batch bursts onto the animation frame Permalink to this section
// Coalesce a burst into one trigger so the view renders at most once per frame.
let pending = [];
let queued = false;
function applyEventBatched(order, eventId) {
pending.push([order, eventId]);
if (queued) return;
queued = true;
requestAnimationFrame(() => {
queued = false;
const batch = pending;
pending = [];
for (const [o, id] of batch) applyEventNoTrigger(o, id);
triggerRef(byId); // one trigger for the whole batch
});
}
Step 5 β Read through computed properties, not by reassignment Permalink to this section
<script setup>
import { computed } from 'vue';
import { useOrdersStore } from '@/stores/orders';
const orders = useOrdersStore();
// Derived views stay reactive because they read through the ref, never replace it.
const openOrders = computed(() =>
[...orders.byId.values()].filter((o) => o.status === 'open'),
);
</script>
Validation & Monitoring Permalink to this section
// 1. Reference counting: mounting two consumers must open exactly one connection.
const a = stream.connect();
const b = stream.connect();
console.assert(countOpenEventSources() === 1, 'more than one connection opened');
a(); console.assert(countOpenEventSources() === 1, 'closed while still in use');
b(); console.assert(countOpenEventSources() === 0, 'not closed by the last release');
// 2. Idempotence: applying the same event twice must not change the store twice.
orders.applyEvent({ id: 7, total: 100 }, 'evt-1');
orders.applyEvent({ id: 7, total: 100 }, 'evt-1');
console.assert(orders.byId.size === 1);
In Vue devtools, the timeline shows store mutations; a burst that produces one mutation rather than one per event confirms the batching works. The Components panel showing thousands of proxy objects is the signal that deep reactivity is still wrapping payloads.
Route changes are the practical regression test: navigate around the application for a minute and check that the Network panel shows one stream, not one per visited route.
Production Checklist Permalink to this section
Frequently Asked Questions Permalink to this section
Should the EventSource live in a store or in a composable?
In a store, with reference counting. A composable is scoped to a component, so several components mean several connections; a store gives one shared connection, but because a store has no lifecycle it needs an explicit count so the last consumer closes it.
Why did my view stop updating after I assigned new data?
Because you replaced a reactive container instead of mutating it. Components hold a reference to the original object, and reassigning leaves them bound to the old one. Mutate the Map or array inside the ref and call triggerRef, or replace ref.value rather than the object a component captured.
Is shallowRef really necessary for streamed data?
Once event rates reach the hundreds per second, yes. Deep reactivity walks every key of every payload to build proxies, and for append-only stream data nothing reads those inner keys reactively β so the work is entirely wasted. A shallow container plus an explicit trigger gives the same view updates at a fraction of the cost.
How large should the deduplication set be?
Large enough to cover a replay window, which is a few hundred to a few thousand ids for most feeds. Bound it β an unbounded set is a slow memory leak β and clear it on resync, since a snapshot makes every earlier id irrelevant.