You are building a Vue 3 log viewer that streams lines until it holds 50,000 rows and must stay responsive; how do you structure its state and rendering?
answer
- immutable lines, shallow state
- batch arrivals per frame
- render a window, key by sequence
- cap the buffer, no deep watch
basics
~20 sHold immutable lines in a shallowRef, batch incoming lines and trigger once per frame, cap the buffer, render only a virtual window keyed by a sequence number, and derive filters with computed instead of deep watchers.
solid answer
~40 sLog lines never change after arrival, so I store them raw in a `shallowRef<LogLine[]>` - no proxies, no per-field tracking. Incoming lines go into a plain buffer and are flushed once per animation frame: push onto the array, trim the oldest beyond the cap, then call `triggerRef()` once, so a burst of messages costs one render. The view is a virtual window: a `computed` slice of the filtered lines feeds `v-for`, keyed by a monotonic sequence number, because array indexes shift as old lines are trimmed from the front. Filtering is a `computed` over the raw array that returns a new array, driven by a debounced query. I avoid any deep watcher; auto-scroll reacts to the flush, not to watching 50,000 objects.
code
ts · 44 linesimport { computed, shallowRef, triggerRef } from 'vue'
export interface LogLine {
seq: number
level: 'info' | 'warn' | 'error'
text: string
}
const MAX_LINES = 50_000
export function useLogBuffer() {
const lines = shallowRef<LogLine[]>([])
const query = shallowRef('')
let pending: LogLine[] = []
let scheduled = false
function ingest(line: LogLine) {
pending.push(line)
if (!scheduled) {
scheduled = true
requestAnimationFrame(flush)
}
}
function flush() {
scheduled = false
const all = lines.value
for (const line of pending) all.push(line)
pending = []
if (all.length > MAX_LINES) all.splice(0, all.length - MAX_LINES)
triggerRef(lines) // one notification per frame, not per line
}
// Always a new array: a computed returning the same reference
// would not notify the view after push + triggerRef.
const filtered = computed(() => {
const q = query.value.toLowerCase()
return q
? lines.value.filter(l => l.text.toLowerCase().includes(q))
: lines.value.slice()
})
return { filtered, query, ingest }
}go deeper
Recall the three levers: keep read-only data shallow, render only the visible rows, and give each row a stable key.
Explain the update path: push to a raw array, trigger once, and let a computed slice feed the v-for window.
Justify each choice from the data's shape: immutability, bursty arrival and a capped buffer, and name the computed-identity trap.
Set the viewer's budget: maximum lines, frame cost per flush and memory ceiling, and decide when filtering belongs on the server.
## What the requirements imply A streaming log viewer has three properties that shape every decision: - **Data is append-only and immutable.** A log line never changes after it arrives. - **Arrival is bursty.** Hundreds of lines can arrive in a second, each in its own network message. - **The dataset is large but the screen is small.** 50,000 lines, of which perhaps 40 are visible. Each property maps to a Vue lever. ## State: immutable lines in a shallowRef Deep reactivity is wasted on data that never changes. A `shallowRef<LogLine[]>` stores the array and its line objects **raw**: no proxies are created and no per-field dependencies are recorded when the view or a filter reads them. Only `.value` is tracked, so updates must either replace `.value` or mutate the raw array and call `triggerRef()`. With 50,000 lines, copying the whole array on every append would itself be linear work, so this design mutates and triggers. ## Ingest: batch arrivals per frame Vue's scheduler already coalesces several changes made in the same task into one component update. But network messages arrive in **separate tasks**, so updating the ref per message produces one render, one filter recompute and one diff per message. Batching fixes that: 1. Push each arriving line into a plain, non-reactive `pending` array. 2. On the first line of a batch, schedule a flush with `requestAnimationFrame`. 3. In the flush, append `pending` to the raw array, then trim from the front beyond `MAX_LINES`. 4. Call `triggerRef(lines)` **once**. At most one update per frame, however fast lines arrive. The cap matters as much as the batching: an unbounded buffer turns a responsive viewer into a memory leak over a long session. ## Render: a virtual window with stable keys Only the visible rows plus some overscan are mounted: a `computed` slice of the filtered lines feeds the `v-for`, a spacer element gives the scrollbar the full height, and an offset positions the slice. Rows are keyed by a **monotonic sequence number** assigned at ingest. Array indexes are not stable here: trimming 200 old lines shifts every index, which with index keys would make each mounted row's key point at a different line. ## Derived views: filter and search Filtering is a `computed` over the raw array. It recomputes only when `lines` is triggered or the query changes, and it reads plain objects, so it costs a straightforward array scan. Two details: - **Return a new array.** In Vue 3.5 a `computed` notifies its dependents only when its new value differs from the previous one. After `push` plus `triggerRef`, a computed that returns `lines.value` itself returns the same reference, so the view would never update. Return `filter(...)` or `slice()`. - **Debounce the query** so typing does not rescan 50,000 lines per keystroke. ## What not to do | Tempting choice | Why it hurts at 50,000 lines | |---|---| | `reactive()` or `ref()` around the lines | proxies and a dependency per field the view reads | | one update per incoming message | a render and a filter pass per network message | | `rows.value = [...rows.value, line]` per line | copies 50,000 items for every line | | index keys with a trimmed front | every mounted row's key changes on each trim | | `watch(lines, cb, { deep: true })` for auto-scroll | a full traversal on every flush | | a heavy component per row | instance cost per mounted row, multiplied by overscan | ## Auto-scroll without a deep watcher Decide 'follow the tail' in the flush function itself: if the user was at the bottom before the flush, set the scroll position after the DOM updates. The information needed is already in hand at flush time, so there is nothing to watch. The interview signal is the chain of reasoning: immutable data means shallow state; bursty arrival means batching; a small screen means virtualization; and each claimed saving is confirmed in a profile, not assumed.
- Your filter computed returns lines.value when the query is empty, and appends stop showing. Why?After `push` and `triggerRef()`, the computed re-runs but returns the same array reference. In Vue 3.5 a computed only bumps its version, and so only re-runs dependents such as the render, when the new value differs from the old one. Same reference means no change is reported. Return `slice()` or have the view read `lines.value` directly.
- Vue already batches updates; why batch arrivals per animation frame yourself?Vue's scheduler coalesces changes made within one task into a single component update. Log lines usually arrive as separate network messages, each its own task, so each would get its own render, filter pass and diff. Buffering lines and triggering once per frame caps the work at one update per frame regardless of message rate.
saying these in an interview costs you the question
- Put the 50,000 lines in reactive() so every field is tracked.
- Update the ref per incoming message; Vue will batch across network messages.
- Key rows by array index even though old lines are trimmed from the front.
- Deep-watch the lines array to know when to auto-scroll.
- Append with a spread per line; copying arrays is effectively free.