Why can mutating reactive state inside a Vue 3 onUpdated hook cause an infinite update loop, and how does Vue react?
answer
- the hook runs after a render
- a new render fires the hook again
- unchanged values do not trigger
- a dev-only recursion counter
basics
~20 sonUpdated runs after a re-render, so changing state the template reads schedules another re-render, which calls onUpdated again. If each pass writes a new value the cycle never settles; in development Vue stops it with a Maximum recursive updates exceeded error.
solid answer
~40 s`onUpdated` is called after the component's re-render has been patched into the DOM. If the hook writes a reactive value that the render reads, and the value actually changes, Vue queues a new render, which ends with `onUpdated` again: a feedback loop. It settles only if a later pass writes the same value, because Vue skips triggers when the value is unchanged. In development builds the scheduler counts how often the same job runs in one flush; past 100 it reports `Maximum recursive updates exceeded in component <Name>`, listing the `updated` hook among the likely sources. Production builds have no counter, so the page can hang. The fix is to derive the value with `computed`, set it where the source changes, or keep `onUpdated` for DOM-only work.
code
ts · 12 linesimport { ref, computed, onUpdated } from 'vue'
const items = ref<string[]>([])
// Loops: every re-render writes a new value the template shows
const renders = ref(0)
onUpdated(() => {
renders.value++
})
// Fixed: derived state needs no hook
const count = computed(() => items.value.length)go deeper
Remember the rule from the Vue docs: do not mutate component state in onUpdated, because it can trigger another update and loop.
Walk through the cycle: onUpdated writes rendered state, the render effect is queued, the patch finishes and onUpdated runs again, and explain why unchanged writes stop it.
Diagnose from the 'Maximum recursive updates exceeded' message, know it is dev-only with a limit of 100, and replace the write with computed, a targeted watcher or DOM-only work.
Treat state writes in onUpdated as a review smell: they tie correctness to render frequency, and converging loops silently multiply render cost across a codebase.
## Why the loop exists In Vue 3 every component has a **render effect**: when the render function runs, Vue tracks which reactive values it reads, and a later change to any of them schedules the component's update job. The update job re-renders, patches the DOM, and then queues the component's `onUpdated` callbacks as **post-flush callbacks**. Now put a write inside `onUpdated`: ```ts import { ref, onUpdated, useTemplateRef } from 'vue' const panel = useTemplateRef<HTMLElement>('panel') const height = ref(0) // shown in the template as {{ height }}px onUpdated(() => { height.value = panel.value!.offsetHeight // writes render state }) ``` The sequence becomes: 1. Something changes; the component re-renders. 2. `onUpdated` runs and writes `height`. 3. `height` is read by the template, so the component is queued again. 4. The re-render patches the DOM, and `onUpdated` runs again: back to step 2. Whether this is infinite depends on the value. Vue only triggers when the new value differs from the old one (an `Object.is` style comparison), so if the second pass writes the same number, nothing is queued and the loop ends after one extra render. If every pass produces a new value, for example a height that grows because the text `"123px"` itself changes the layout, or a counter that is incremented, it never settles. ## What Vue does about it | Build | Behaviour | |---|---| | Development | The scheduler keeps a per-flush count of how often each job runs. When one job exceeds **100** runs, Vue reports `Maximum recursive updates exceeded in component <Name>` and skips that job. | | Production | The counting code is stripped. Nothing interrupts the cycle, so the tab can freeze or run out of stack. | The full development message explains that "you have a reactive effect that is mutating its own dependencies and thus recursively triggering itself" and lists the usual sources: the **component template, render function, updated hook or watcher source function**. Seeing it is a strong hint to look at `onUpdated` first. The warning is a safety net, not a fix. A loop that happens to converge after 20 iterations never trips it, yet still costs 20 renders per change. ## Why onBeforeUpdate does not have this problem `onBeforeUpdate` runs inside the render effect, before the render function, while Vue has self-recursion switched off. A write there is simply read by the render that follows. Of the two update hooks, only `onUpdated`, which runs after the render has finished, can start a new cycle this way. ## How to fix it Pick the tool that matches what the value really is: - **Derived from other state** → use `computed`. It is cached and needs no hook at all. - **Caused by one specific change** → set it where that change happens, or watch that source and, if the DOM is needed, `await nextTick()` or give the watcher `flush: 'post'`. - **Needs a DOM measurement** → measure in `onUpdated` but store the result in a plain variable, or guard the write so it only happens when the value differs, and make sure the layout does not depend on the stored value. - **Purely visual follow-up** → do DOM work directly (focus, scroll, a third-party widget refresh) instead of writing reactive state. A guarded write, fixed so it cannot oscillate: ```ts onUpdated(() => { const h = panel.value!.offsetHeight if (h !== height.value) height.value = h // converges once layout is stable }) ``` The guard only helps when the displayed value does not itself change the layout; if it does, move the measurement out of the render cycle entirely, for instance by observing size changes on the element. ## A realistic loop that looks innocent Loops rarely come from an obvious `counter++`. A more typical case is **measure, then display**: a card checks in `onUpdated` whether its title overflows and sets an `isTruncated` ref that shows a small tooltip icon. The icon takes horizontal space, so the title now overflows less, or no longer at all; the next `onUpdated` sets `isTruncated` back to `false`, the icon disappears, the title overflows again, and the component oscillates between two states. Each pass writes a *different* value, so the change check never stops it. The cure is to break the dependency between the measurement and what it controls: reserve space for the icon so its presence does not change the measured width, or measure an element whose size does not depend on the result. ## What interviewers listen for - That the loop comes from **the hook's position after the render**, not from a Vue bug. - That **unchanged writes do not trigger**, so the problem is writes that keep producing new values. - That the recursion error is **development-only** and caps at 100 runs of one job per flush. - That the fix is structural: `computed`, a targeted watcher, or DOM-only work in the hook.
- Does every write in onUpdated cause a loop?No. Only a write that changes a value the component's render reads queues another render. Writes to non-reactive variables, to state the template never reads, or writes of an unchanged value trigger nothing. A guarded write that converges costs one extra render rather than looping, although it is still usually a sign the value belongs in a `computed`.
- Why might the loop never show the dev warning yet still hurt performance?The warning fires only when one job runs more than 100 times within a single flush. A cycle that converges after a few passes, or that is re-started by each user event, stays under the limit, yet every change now costs several renders and layout reads. Profiling render counts, not waiting for the warning, is how you find it.
saying these in an interview costs you the question
- Vue prevents state changes inside onUpdated entirely
- Setting a ref to the same value in onUpdated also loops
- The Maximum recursive updates error also stops the loop in production
- Moving the write into onBeforeUpdate still causes the same loop
- The loop is a Vue bug that a newer version fixes