Error Handling & Debugging
onErrorCaptured, app.config.errorHandler, error boundaries, Vue DevTools and common warnings.
Sooner or later a component throws: an API returns something unexpected, a value is null where the template expected an object, a handler has a bug. Vue gives you three layers for dealing with that: onErrorCaptured in a parent component, a global app.config.errorHandler, and good old try/catch. This lesson shows each one working, then covers Vue DevTools and the console warnings you'll see most.
Try it: an error boundary
The card below is wrapped in an error boundary: a component that catches errors from anything inside it and shows a fallback instead of a broken page. Break the render, throw in a click handler, or trigger an async error from a real API call that returns 404. Then press Try again.
Where errors can come from
Vue catches errors thrown in almost every place it runs your code, and sends them up the component tree:
| Source | Example | Caught by Vue? |
|---|---|---|
setup() |
Reading a property of undefined |
Yes |
| Render / template | {{ task.assignee.name }} when assignee is null |
Yes |
| Lifecycle hooks | onMounted(() => { … }) |
Yes |
Watchers and computed |
A watch callback that throws |
Yes |
| Event handlers | @click="save" where save throws |
Yes |
| Async event handlers | @click="load" where load is async and rejects |
Yes: Vue catches the returned promise |
setTimeout, fetch().then() you don't return |
A callback outside Vue's call | No: it goes to window.onerror / unhandledrejection |
The last row is the one that bites. If you start async work that Vue can't see, catch its errors yourself:
onMounted(() => {
setTimeout(() => {
try {
riskyThing()
} catch (e) {
error.value = e.message
}
}, 1000)
})
onErrorCaptured: handle errors from children
onErrorCaptured(err, instance, info) runs in a parent whenever a descendant throws. Return false to say "handled" and stop the error travelling further up. This is the boundary from the demo, written with a render function so it can swap its whole content:
public/tutorial-assets/vue/error-handling.js
const ErrorBoundary = {
emits: ['caught'],
setup(props, { slots, emit }) {
const error = Vue.ref(null);
const attempt = Vue.ref(0);
Vue.onErrorCaptured((err, instance, info) => {
error.value = err;
emit('caught', { message: err.message, info });
return false; // handled: stop it reaching app.config.errorHandler
});
function retry() {
error.value = null;
attempt.value++; // new key → children re-created from scratch
}
return () => error.value
? Vue.h('div', { class: 'warning', style: 'margin:0' }, [
Vue.h('p', { style: 'margin:0 0 .5rem' }, [Vue.h('strong', 'Something went wrong in this panel. '), error.value.message]),
Vue.h('button', { onClick: retry }, 'Try again'),
])
: Vue.h('div', { key: attempt.value }, slots.default ? slots.default() : []);
},
};
Using it is just wrapping:
<ErrorBoundary>
<TaskCard slug="no-such-project" />
</ErrorBoundary>
key. A new key makes Vue throw away the broken children and create fresh ones, so their state starts clean. Without it, the child would re-render with the same bad data and fail again.
onErrorCaptured only sees errors from descendants, not from the component that registers it. That's why a boundary is a separate wrapper component.
app.config.errorHandler: the global net
Anything not handled by a boundary reaches the app-level handler. This is where you report errors to a monitoring service. Without one, Vue just logs to the console.
const app = createApp(App)
app.config.errorHandler = (err, instance, info) => {
// instance: the component that threw (or null)
// info: where it happened, e.g. "native event handler"
reportToMonitoring(err, { component: instance?.$options.name, info })
}
app.mount('#app')
Try it: each button throws from a different place, and the handler below records it.
info is a readable label like "setup function" or "watcher callback". Production builds (including the one these demos use) pass a short link instead, such as https://vuejs.org/error-reference/#runtime-5, to save bytes. The page it points to explains the code.
In a Vue + Laravel app, a common pattern is to POST the error to a small Laravel endpoint that writes it to the log, so front-end and back-end errors end up in the same place. Services such as Sentry ship a Vue integration that installs this handler for you.
Handling expected errors with try/catch
Boundaries and global handlers are for unexpected failures. When failure is a normal outcome, such as a 404 or a validation error, handle it where it happens and show a proper message:
fetch only rejects on network failure, not on HTTP errors, so check response.ok yourself. The Fetching Data lesson builds this into a reusable composable.
app.config.warnHandler
Vue also emits warnings: problems that don't crash, such as a missing required prop. app.config.warnHandler(msg, instance, trace) lets you route them somewhere, for example to fail tests on any warning.
warnHandler never fires in production. Use the development build (vue.global.js, or npm run dev with Vite) while building.
Vue DevTools
The Vue DevTools browser extension (or the Vite plugin, vite-plugin-vue-devtools) adds a Vue panel to the browser's developer tools:
| Tab | Shows |
|---|---|
| Components | The live component tree, each one's props, state and computed values, editable in place |
| Timeline | Events, emits, Pinia actions and router navigations, in order |
| Pinia | Every store's state, with time-travel |
| Router | Current route, params and the route table |
DevTools only connects to development builds. Start there whenever a value "should" be different: you can see exactly what each component holds.
Common warnings and their fixes
These are the console messages you'll meet most while developing:
| Warning | Usual cause | Fix |
|---|---|---|
Missing required prop: "task" |
Parent forgot the prop, or misspelled it | Pass it, or check :task vs task and camelCase vs kebab-case |
Failed to resolve component: TaskCard |
Not imported or not registered | Import it in <script setup>, or register it globally |
Property "count" was accessed during render but is not defined |
Used in the template, not returned or declared | Declare it in <script setup> (or return it from setup()) |
Extraneous non-props attributes (class) were passed |
Component has several root nodes | Bind v-bind="$attrs" on one of them, or use one root |
Avoid app logic that relies on enumerating keys on a component instance |
Code looping over this |
Loop over your own data instead |
Cannot read properties of null (reading 'name') |
Template reads into data that hasn't loaded | Guard with v-if="task" or optional chaining task?.assignee?.name |
Maximum recursive updates exceeded |
A watcher or computed changes the value it depends on | Break the loop: derive with computed instead of writing in a watcher |
Duplicate keys / v-for without :key |
Missing or repeated :key |
Use a stable, unique id |
Key takeaways
- Vue catches errors from setup, render, lifecycle hooks, watchers and event handlers, including rejected promises returned by async handlers. Work it can't see, such as
setTimeout, needs its owntry/catch. - An error boundary is a wrapper component using
onErrorCaptured. Returnfalseto mark the error handled, show a fallback, and change akeyto retry with fresh children. app.config.errorHandleris the global net for anything unhandled: report errors to monitoring from there.- Handle expected failures such as 404s and validation errors with
try/catchwhere they happen, and checkresponse.okwithfetch. - Warnings and Vue DevTools are development-only. Develop with the dev build and keep the console clean.