Merging Live Data into State
Apply an incoming change to one row, keep the viewport still, and stop an in-flight refetch from overwriting newer live data.
rows.value = applyEvent(rows.value, event)A live event describes a change: one record created, one field updated, one record gone. The screen already holds everything else. Refetching the whole list on every event throws that away, resets the scroll, and loses the row the user was reading.
Apply by id
Find the record by id and replace it, append it when it is new, filter it out when it is gone. Do the write immutably, returning a new array, so the merge is one assignment and one pure function you can test without mounting anything. In the template, v-for needs a stable key: :key="row.id", which is the shorthand for v-bind:key="row.id" and compiles to the same binding. With a stable key Vue patches that row's existing DOM node, which is what preserves scroll position, text selection and focus inside the row. The array index as a key breaks the moment a row is inserted or removed, because every key after the change now points at different data.
Keeping the viewport still
Appending below the fold moves nothing. Prepending pushes everything down. In a watcher, record el.scrollHeight - el.scrollTop, await nextTick() so the new rows exist in the DOM, then set el.scrollTop = el.scrollHeight - recorded.
Reconciling with a cache
Carry a version or updatedAt on every record and ignore any event that is not newer than what you hold. Write live changes into the same cache the query reads from, not beside it; with TanStack Query that is queryClient.setQueryData(key, updater). After a reconnect, refetch: nothing tells you what the stream missed while it was down.
Example
// src/lib/applyEvent.ts
export interface Row {
id: string
text: string
version: number
}
export type LiveEvent =
| { type: 'upsert'; row: Row }
| { type: 'remove'; id: string }
export function applyEvent(rows: Row[], event: LiveEvent): Row[] {
if (event.type === 'remove') {
return rows.filter((row) => row.id !== event.id)
}
const index = rows.findIndex((row) => row.id === event.row.id)
const current = index === -1 ? undefined : rows[index]
if (current === undefined) {
return [...rows, event.row]
}
if (current.version >= event.row.version) {
return rows
}
const next = rows.slice()
next[index] = event.row
return next
}
const rows: Row[] = [{ id: 'a', text: 'one', version: 2 }]
console.log(applyEvent(rows, { type: 'upsert', row: { id: 'a', text: 'stale', version: 1 } }))
// Output: [ { id: 'a', text: 'one', version: 2 } ]
console.log(applyEvent(rows, { type: 'upsert', row: { id: 'a', text: 'two', version: 3 } }))
// Output: [ { id: 'a', text: 'two', version: 3 } ]
console.log(applyEvent(rows, { type: 'remove', id: 'a' }))
// Output: []When to use it
- Insert a new chat message without jumping the reader away from the line they were on.
- Update one row of a live orders table while the user's filter and sort stay applied.
- Merge socket updates into a list a query has already cached.
- Rebuild state after a reconnect by refetching the snapshot and replaying later events.
More examples
The store that owns the list
A setup store, the form Pinia 4 documents alongside the older option store: refs become state, functions become actions, and every state ref must appear in the returned object or Pinia will not treat it as state. The store is the right home for a live list because a socket outlives any single component and two screens may show the same data. Keeping lastEventId here is what makes recovery possible: after a reconnect you call loadSnapshot with a fresh fetch, or ask the server for everything after that id.
// src/stores/feed.ts
import { defineStore } from 'pinia'
import { ref } from 'vue'
import { applyEvent, type LiveEvent, type Row } from '@/lib/applyEvent'
export const useFeedStore = defineStore('feed', () => {
const rows = ref<Row[]>([])
const lastEventId = ref<string | null>(null)
function loadSnapshot(initial: Row[]) {
rows.value = initial
lastEventId.value = null
}
function apply(event: LiveEvent, eventId: string) {
rows.value = applyEvent(rows.value, event)
lastEventId.value = eventId
}
return { rows, lastEventId, loadSnapshot, apply }
})Prepending without moving the viewport
The list scrolls inside its own box, and when rows arrive at the top the reader stays on the line they were reading. The watcher callback runs before Vue patches the DOM, so the measurement is of the old layout; nextTick() resolves after the patch, and the new scrollTop restores the same distance from the bottom. useTemplateRef('scroller') is the Vue 3.5 way to read a ref="scroller" element; the older equivalent, which still works, is const scroller = ref<HTMLElement | null>(null) with a matching ref attribute name.
<!-- src/components/FeedList.vue -->
<script setup lang="ts">
import { nextTick, useTemplateRef, watch } from 'vue'
import { useFeedStore } from '@/stores/feed'
const feed = useFeedStore()
const scroller = useTemplateRef<HTMLElement>('scroller')
watch(
() => feed.rows.length,
async () => {
const el = scroller.value
if (!el) return
const distanceFromBottom = el.scrollHeight - el.scrollTop
await nextTick()
el.scrollTop = el.scrollHeight - distanceFromBottom
}
)
</script>
<template>
<ul ref="scroller" class="feed">
<li v-for="row in feed.rows" :key="row.id">{{ row.text }}</li>
</ul>
</template>
<style scoped>
.feed {
max-height: 20rem;
overflow-y: auto;
}
</style>Writing a live event into a query cache
When a cache already holds the list, the live event belongs in the cache, not in a second copy beside it. setQueryData takes the query key and an updater that receives the cached value and returns the new one; the update must be immutable, which applyEvent already is. Returning old unchanged when there is nothing cached leaves the cache alone, so the next fetch is still the first fetch. Use this when every live change is a shape you can apply locally; when an event only hints that something changed, invalidate the key instead and let the query refetch.
// src/realtime/useFeedSync.ts
import { useQueryClient } from '@tanstack/vue-query'
import { applyEvent, type LiveEvent, type Row } from '@/lib/applyEvent'
export function useFeedSync() {
const queryClient = useQueryClient()
return function onLiveEvent(event: LiveEvent) {
queryClient.setQueryData(['feed'], (old: Row[] | undefined) =>
old ? applyEvent(old, event) : old
)
}
}
Discussion