Something is not updating, or is updating twice, or the page is blank after a refresh. Work through the checks below in order, cheapest first, and stop when one shows you the cause; the last section lists them all on one screen.
Run the app on Vite's development server (npm run dev) while you do. That is where Vue's warnings, its DevTools support and the source maps are switched on.
Read the warning first
Vue's development build prints a [Vue warn] for most mistakes, with the component stack underneath. Read it to the end before touching anything. These four are worth knowing by sight.
Extraneous non-props attributes. The full text is Extraneous non-props attributes (class) were passed to component but could not be automatically inherited because component renders fragment or text or teleport root nodes. It means you wrote <FieldLabel class="mt-2" /> and the component has more than one root element, so Vue did not know which one should receive the class, and dropped it. Here FieldLabel renders a <label> and a <small>:
<script setup lang="ts">
defineProps<{ label: string; hint: string }>()
</script>
<template>
<label>{{ label }}</label>
<small>{{ hint }}</small>
</template>
The fix is to choose the element yourself:
<script setup lang="ts">
defineOptions({ inheritAttrs: false })
defineProps<{ label: string; hint: string }>()
</script>
<template>
<label v-bind="$attrs">{{ label }}</label>
<small>{{ hint }}</small>
</template>
inheritAttrs: false turns off the automatic forwarding, and v-bind="$attrs" places the class, the id and any listeners on the element you picked.
Missing or duplicate keys. A v-for without a :key gets no runtime warning; the lint rule vue/require-v-for-key from eslint-plugin-vue catches it before the code runs. Without keys Vue reuses rows by position, so after a sort a checkbox can stay ticked on the wrong row. Vue does warn when two items share a key and the list updates: Duplicate keys found during update:, then the key, then Make sure keys are unique. The key is usually a field that is not unique, or two lists were joined. Key on a real id.
Mutating a prop. Set operation on key "count" failed: target is readonly. You wrote props.count++. Props flow down; to change the value, emit an event and let the parent change it, or copy the prop into a local ref if the component really owns the edit.
Hydration mismatch. This one appears only in server-rendered apps such as Nuxt. While hydrating (taking over the server's HTML), the browser computed a different value from the server's, and Vue replaced the server's text with its own. The warning names what differed, for example Hydration text content mismatch on and the element, then rendered on server: and expected on client: with both values.
The usual causes are the current time, Math.random(), or something only the browser knows, such as window.innerWidth or a value in localStorage. Read those in onMounted(), which runs in the browser only, or wrap the markup in Nuxt's <ClientOnly>. This Nuxt component greets by the hour without a mismatch:
<script setup lang="ts">
import { onMounted, ref } from 'vue'
const hour = ref<number | null>(null)
onMounted(() => {
hour.value = new Date().getHours()
})
</script>
<template>
<p v-if="hour === null">Hello</p>
<p v-else>{{ hour < 12 ? 'Good morning' : 'Good afternoon' }}</p>
</template>
Server and browser both render "Hello" first; the greeting appears once the component is mounted.
Check the state in Vue DevTools
Vue DevTools comes as a browser extension, which adds a Vue panel to the developer tools, and as the vite-plugin-vue-devtools package, which draws the same panels inside your page during vite dev. The extension needs nothing from your project beyond a development build: a production build leaves out the code that connects Vue to DevTools, so the panels stay empty on your live site.
Version 8.0 was published on 27 July. Its release notes list two breaking changes, both in the Vite plugin: Vite versions below 6 are no longer supported, and the bundled vite-plugin-inspect was bumped.
Two panels do most of the work. Components shows the tree as Vue sees it, not the DOM. Click a component and the pane beside it lists its props, its setup state, its computed values and anything it injected, live. Edit a value there and the page updates. That answers the first real question: is the state wrong, or is the template ignoring it? The inspector button lets you click an element on the page to jump to its component, and the open-in-editor button opens the file.
Timeline records what happened, in order, in four layers: mouse, keyboard, component events and performance. It records nothing until you press its record button and enable the layers you need (recording slows a large app). The performance layer lists each render with its component and duration, so something that updates twice shows as two entries, and a slow form shows which render took the time.
Log the value, not the ref
console.log(count) prints RefImpl {…} with the number buried in _value, which makes a working ref look broken. Log count.value. For a reactive object, console.log(state) prints a Proxy, and worse, the console shows the object as it is when you expand it, not as it was when you logged it. Unwrap and snapshot:
import { toRaw } from 'vue'
console.log(count.value)
console.log(structuredClone(toRaw(state)))
toRaw() returns the plain object behind the proxy; structuredClone() copies it at that instant (it refuses objects that hold functions). For something that changes over time, a watch with the values in the callback beats scattered logs, because it tells you what changed and when. In a search component with query and results refs:
import { watch } from 'vue'
watch(
() => [query.value, results.value.length] as const,
([q, n]) => console.log(`query ${JSON.stringify(q)} gave ${n} results`),
)
Chrome's console can also show refs unwrapped: enable custom formatters in the developer tools settings, and Vue's development build registers one.
Pause the code with a breakpoint
Vite serves source maps in development, so the browser's Sources panel shows your .vue files rather than the compiled output. Press Cmd+P (Ctrl+P on Windows and Linux) inside the developer tools, type the component's file name, and click a line number in the <script setup> block to set a breakpoint. Or write debugger on the line; it stops there whenever the developer tools are open:
function addToCart(item: Product) {
debugger
cart.add(item)
}
Stop on the first line of a handler to confirm it runs at all, and with the arguments you expect. Step with F10 (over) and F11 (into) and keep an eye on the Scope pane. A breakpoint answers in one minute what ten logs answer in ten.
You can also stop inside a watch callback or a computed, but the top of the call stack will be Vue's code: Vue runs a watch callback later, from its own queue, and recalculates a computed when something reads it, usually the next render. Below a line marked Promise.then (async), Chrome shows the change that started that queue. When a handler changes several values, that can be an earlier one than the change your watcher follows.
The onTrigger option runs at the moment of the change itself, so a debugger there has the line that made it on the call stack. Here it is on a watcher that runs search() when query changes:
watch(query, search, {
onTrigger(event) {
debugger // event.key and event.newValue say what changed
},
})
computed() takes the same option as its second argument, and the onRenderTriggered() hook does the same for a component that re-renders unexpectedly. All three work in development only.
Shrink the bug in the playground
When the bug survives all that, make it smaller. The Vue SFC Playground runs a component in the browser with no build step. Paste the component in, replace the data it fetches with a literal, delete everything that is not involved, and see whether the bug is still there. If it disappears, the cause is in what you deleted: a parent, a store, a global style. Put things back one at a time until it returns. If it stays, you have a twenty-line reproduction, which is exactly what a bug report or a forum question needs.
Bisect the template with v-if
For "something in this page is wrong and I do not know what", split the problem in half. Wrap the top half of the template in <template v-if="false"> and reload. If the bug is gone, it was in that half; if it stays, it is in the other. Move the v-if and repeat. Four rounds narrow a 200-line template to about twelve lines. git bisect does the same with your history: mark a commit that works and one that does not, and it halves the range between them until it names the commit that broke it.
The checklist in order
- Read the console. Fix every
[Vue warn]before chasing the bug; one of them is often the bug. - Open Vue DevTools, select the component, and decide whether the state is wrong or the template is.
- Log
.value, not the ref, and snapshot reactive objects withstructuredClone(toRaw(state)). - Put a breakpoint or
debuggeron the first line of the handler and step through it. To see what set off a watcher, put it inonTrigger. - Reproduce it in the playground with literal data.
- Bisect the template with
v-if, or the history withgit bisect. - Write the failing test before the fix, so the bug cannot come back quietly.