skip to content

In a Vue 3 chat log that must stay scrolled to the bottom, should the scrolling go in onUpdated, a post-flush watcher, or after nextTick()?

level: middleimportance: should knowfreq 45%

answer

  1. what else re-renders this component
  2. tie the scroll to new messages
  3. the DOM is patched asynchronously
  4. capture 'was at bottom' before the patch

basics

~20 s

Tie the scroll to the change that needs it: watch the messages with flush: 'post', or await nextTick() after appending one. onUpdated also fires for unrelated re-renders, such as typing in the input, so it would yank the view on every keystroke.

solid answer

~40 s

Vue applies DOM updates asynchronously, so scrolling right after `messages.value.push(m)` measures the old list. `onUpdated` would see the new DOM, but it runs after **every** re-render of the component, including typing in the draft input or a ticking timestamp, so the log would jump to the bottom for reasons that have nothing to do with messages. The targeted options are a watcher on the newest message's id with `flush: 'post'`, whose callback runs after the component's DOM has been updated, or `await nextTick()` in the code path that appends the message. For a polished log, record in `onBeforeUpdate` whether the user was already at the bottom, and only auto-scroll if so, so reading history is not interrupted.

code

vue · 29 lines
vue
<script setup lang="ts">
import { ref, watch, onBeforeUpdate, useTemplateRef } from 'vue'

const messages = ref<{ id: number; text: string }[]>([])
const draft = ref('')
const log = useTemplateRef<HTMLElement>('log')
let wasAtBottom = true

onBeforeUpdate(() => {
  const el = log.value
  if (el) wasAtBottom = el.scrollTop + el.clientHeight >= el.scrollHeight - 4
})

watch(
  () => messages.value.at(-1)?.id,
  () => {
    const el = log.value
    if (el && wasAtBottom) el.scrollTop = el.scrollHeight
  },
  { flush: 'post' },
)
</script>

<template>
  <ul ref="log" class="log">
    <li v-for="m in messages" :key="m.id">{{ m.text }}</li>
  </ul>
  <input v-model="draft" />
</template>

go deeper

for a junior

Remember that Vue updates the DOM asynchronously, so a scroll must wait for nextTick() or a watcher with flush: 'post'.

for a middle

Explain why onUpdated fires for every re-render, including typing, and why a watcher on the newest message id with flush: 'post' targets exactly the right change.

for a senior

Add the reader-friendly detail: capture wasAtBottom in onBeforeUpdate without touching reactive state, and avoid shallow watches that miss array pushes.

for a principal

Argue for tying DOM side effects to data events rather than render events, so refactors that change render frequency cannot change behaviour.

## The problem in one sentence A chat log must scroll to its newest message **after** Vue has inserted that message into the DOM, and **only** when a message was added. The three candidate tools differ on both points. ## Why a synchronous scroll fails Vue batches state changes and patches the DOM on the next flush. So this does not work: ```ts messages.value.push(msg) list.value!.scrollTop = list.value!.scrollHeight // old height: new row not rendered yet ``` The scroll height is read before the new row exists, so the view stops one message short. ## Comparing the three options | Option | Sees the new DOM | Fires only for new messages | Notes | |---|---|---|---| | `onUpdated` | yes | **no**, every re-render | Typing in the draft box, a presence badge or a timestamp tick also re-render the component | | `watch(source, cb, { flush: 'post' })` | yes | yes, when the watched source changes | Declarative; works however the message arrives | | `await nextTick()` after the push | yes | yes, at that call site | Simple, but every code path that appends must remember it | **`onUpdated`** answers "the component re-rendered", not "a message arrived". Most chat components hold more than the list: a `v-model` draft, a "someone is typing" indicator, relative timestamps. Each keystroke in the draft re-renders the component and fires `onUpdated`, so a user scrolled up to read history is dragged back down while typing. It also makes the scroll depend on render frequency, a coupling that breaks when the component is refactored. **A post-flush watcher** watches exactly the thing that matters. With `flush: 'post'`, the callback runs after Vue has updated the owner component's DOM, so `scrollHeight` already includes the new row. By default a watcher callback runs before the owner's DOM update, which is why the option is needed here. **`nextTick()`** returns a promise that resolves after the pending DOM update flush. It is the right choice when the append happens in one place you control, such as a send handler. ## Not interrupting the reader Auto-scrolling a user who deliberately scrolled up is a classic UX bug. `onBeforeUpdate` is the right hook to capture the **old** scroll position, because the DOM has not been patched yet, and it writes only a plain variable, so no extra render is caused: 1. In `onBeforeUpdate`, compute `wasAtBottom` from `scrollTop`, `clientHeight` and `scrollHeight`. 2. In the post-flush watcher on the newest message's id, scroll only if `wasAtBottom` is true. 3. Optionally show a "new messages" badge when it is false. This is a legitimate use of an update hook: it reads DOM state before the patch and stores it outside reactive state. ## Pitfalls to mention - **Writing reactive state in `onUpdated`**, such as storing the measured height in a ref the template shows, can re-trigger the render and loop. - **Watching the array without `deep`**: `watch(messages, ...)` on a ref holding an array is not triggered by `push`, because the ref's value is still the same array. Watching a getter such as `() => messages.value.at(-1)?.id` avoids the question. - **Watching the length**: loading older history at the top also changes the length, so a length watcher would jump to the bottom exactly when the user asked for older messages. The newest message's id changes only when something is appended. - **Assuming `flush: 'post'` waits for child components** that load asynchronously; it waits for the current flush, not for code that has not arrived yet. - **Forgetting that `nextTick()` must be awaited**; calling it without `await` or a callback changes nothing. ## Where the messages come from A real chat appends from several places: the send handler, a socket or polling callback, a reconnect that replays missed messages. With `nextTick()`, each of those paths needs its own `await nextTick()` and scroll call, and the next developer who adds a path will forget it. A single watcher on the data covers every path at once, which is why it is usually the better default in a component that only *displays* the log. `nextTick()` remains the natural fit inside one function that both appends and needs to act on the result, for example focusing the new row after sending. ## The answer an interviewer wants Use a targeted, post-DOM-update hook: a `flush: 'post'` watcher on the newest message's id, or `await nextTick()` at the append site. Keep `onUpdated` out of it because it fires for every re-render, and use `onBeforeUpdate` only to remember whether the reader was already at the bottom.

  • Why watch the newest message's id rather than messages itself or its length?
    Watching the ref directly fires only when `.value` is replaced; `push` mutates the same array, so a shallow watch misses it. The length changes on every append, but also when older history is prepended, which would jump the reader to the bottom. A getter returning the last message's id changes only when a new message lands at the end, with no deep traversal.
  • Why is it safe to use onBeforeUpdate here when onUpdated writes are discouraged?
    The `onBeforeUpdate` callback only reads DOM measurements and stores the result in a plain `let` variable, not in reactive state. Nothing the render reads changes, so no extra render is scheduled. And the old scroll position is only available before the patch, which is exactly when this hook runs.

onUpdated is a doorbell that rings for every delivery to the building; a watcher on the message count is a notification for your own parcel. You want to open the door, scroll the log, only for your parcel.

saying these in an interview costs you the question

  • Scroll right after push; the DOM updates synchronously
  • onUpdated only fires when the messages array changes
  • A default watcher callback already runs after the owner's DOM update
  • nextTick() changes the DOM timing even if not awaited
  • Always auto-scroll, even when the user scrolled up