Error handling and debugging
Stop an error close to where it happened with onErrorCaptured(), wrap risky parts of a page in an error boundary with a retry, report the rest from app.config.errorHandler, and know which errors no Vue hook sees.
What you'll learn
When you finish, you can do these.- 1Stop a child's error in its parent with onErrorCaptured()
- 2Build an error boundary with a fallback and a retry
- 3Report unhandled errors with app.config.errorHandler
- 4Spot and fix a timer error that skips every boundary
A shop page shows a stock widget whose Check stock button asks a stock service how many mugs are left. One day the service times out, and the click handler, the function the button runs, throws an error. The check stops halfway and the visitor sees no message. Vue lets you catch the error close to where it happened, show a fallback message in place of the broken part, and still report it once to an error tracker, a service that collects errors.
The Code tab holds that page. StockWidget.vue is the widget, and every third check fails. ErrorBoundary.vue is an error boundary, a wrapper that puts the fallback and a Try again button in the widget's place when it throws. main.ts, the file that starts the app, sets one handler for the errors nothing else stopped. To build the wrapper, you need one idea from Slots: a slot is where a component shows the content its parent puts between its tags.
How an error travels up the tree
To catch an error, you need the path Vue sends it along. Vue checks for errors in every piece of your code it calls: <script setup>, the template's render, lifecycle hooks such as onMounted, watchers and event handlers.
When one throws, Vue starts at the parent of the component that threw and walks up the component tree, handing the error to each ancestor that registered onErrorCaptured(), the hook for errors from the components inside it. In the Code tab, the walk starts at the widget's parent, ErrorBoundary.vue.
- Child throws
- Parent onErrorCapturedreturn false stops it
- Higher ancestors
- app.config.errorHandler
- Tracker or console
If the hook returns false, the error counts as handled and the trip ends. If no hook stops it, it reaches app.config.errorHandler, one function for the whole app. If there is no handler either, development prints a warning and throws the error, and production logs it with console.error.
The walk starts at the parent, so a component's own onErrorCaptured() never sees errors thrown by that same component. That is why the catching code lives in a separate component that you wrap around the risky part.
Build an error boundary
An error boundary is a component whose only job is to catch errors from the components inside it and show a fallback in their place. Everything outside it keeps working. In the Code tab it is ErrorBoundary.vue. Its script keeps the caught error in a ref, and report(), from errorLog.ts, adds a line to the Error log, a list under the widget.
<script setup lang="ts">
import { ref, onErrorCaptured } from 'vue'
import { report } from './errorLog'
const error = ref<Error | null>(null)
onErrorCaptured((err) => {
error.value = err as Error
report(`Boundary: ${error.value.message}`)
return false
})
function retry() {
error.value = null
}
</script>
When a component inside throws, the hook stores the error, logs a Boundary line and returns false, so the error stops here. retry() clears it again.
The template shows one of two slots, the places where the parent's content goes:
<template>
<slot v-if="error" name="fallback" :error="error" :retry="retry">
<p>This part failed: {{ error.message }}</p>
<button @click="retry">Try again</button>
</slot>
<slot v-else />
</template>
The default slot, with no name, holds the risky content. When something inside throws, error fills and the fallback slot takes its place.
That slot hands error and retry to the parent as slot props, values the parent reads with <template #fallback="{ retry }">, so each page can word its own message. If the parent gives no fallback, the slot's own content shows: the message and a Try again button.
Try again calls retry(). The content sits in the v-else branch, so Vue builds it again from scratch: the widget starts with fresh state.
In StockWidget.vue, Check stock calls check(), which throws a timeout error every third time. App.vue, the top component, wraps the widget:
<ErrorBoundary>
<StockWidget name="Mugs" />
</ErrorBoundary>
In the preview, click Check stock three times. The third check throws, the widget is replaced by This part failed: Mugs: stock service timed out, and the log gets one line from the boundary. Click Try again and the widget is back at 0 checks.
Report what nobody handled
app.config.errorHandler is the last stop for an error that no boundary stopped, and the one place to send errors to a tracker. Set it once in main.ts, the file that creates the app, before mount(), so errors during the first render are caught too.
app.config.errorHandler = (err, instance, info) => {
report(`errorHandler: ${(err as Error).message} (${info})`)
}
The handler receives the error, the component that threw (instance) and info, a short string saying what Vue was running, such as native event handler. In the Code tab, report() stands in for the tracker. Once a handler is set, Vue gives it the error and does nothing else: no warning, no throw.
When you debug, read the end of each Vue warning: a component trace such as at <StockWidget name="Mugs" > at <ErrorBoundary> at <App> says where to look.
What goes wrong
The first mistake is a boundary hook without return false: each error is reported twice.
onErrorCaptured((err) => {
error.value = err as Error
report(`Boundary: ${error.value.message}`)
})
The fallback still appears, but the error keeps travelling. For one failure the log shows Boundary: Mugs: stock service timed out and then errorHandler: Mugs: stock service timed out (native event handler), so your tracker records an error the user already saw handled. Put return false back as the hook's last line:
onErrorCaptured((err) => {
error.value = err as Error
report(`Boundary: ${error.value.message}`)
return false
})
The second mistake is a delayed check. Picture a Check later button in StockWidget.vue whose checkLater() waits half a second with setTimeout, the browser's timer, then calls check().
function checkLater() {
setTimeout(() => check(), 500)
}
On the third late check the count goes up, but no fallback or log line appears. The preview shows Mugs: stock service timed out as an uncaught error. Vue only catches code it calls: it called checkLater, which returned at once, and the browser ran the timer's function half a second later, outside Vue.
The fix makes checkLater() an async function, which returns a promise, a stand-in for a later result, and awaits the delay:
async function checkLater() {
await new Promise(resolve => setTimeout(resolve, 500))
check()
}
Vue keeps the promise an async handler returns and catches it when it fails, so the error reaches the boundary.
A promise you follow with .then(), without returning or awaiting it, stays invisible to Vue, like the timer. Errors Vue never sees land on the window's error and unhandledrejection events, which error-tracking libraries listen to for you.
What to remember
- Vue walks an error up the tree, starting at the parent of the component that threw. Each ancestor's
onErrorCaptured()sees it: returnfalseto stop it, or it goes on toapp.config.errorHandler. - An error boundary is a wrapper component with
onErrorCaptured(), a fallback slot and a retry that builds its content again. app.config.errorHandler, set inmain.tsbeforemount(), is the one place to report to a tracker.- Errors from timers and promises Vue never sees skip every boundary and the handler:
awaitthem inside an async handler, or catch them with the window'serrorandunhandledrejectionevents.
Try it
Open the Code tab and change the starter code. Each task says what you should see.- In
ErrorBoundary.vue, delete thereturn falseline. Click Check stock three times: the log shows two lines for the one failure, one from the boundary and one from errorHandler. Put the line back and the next failure is logged once. - In
App.vue, add a second boundary around<StockWidget name="Shoes" />with your own fallback:<template #fallback="{ retry }"><p>Shoes are unavailable right now.</p><button @click="retry">Reload shoes</button></template>. Click Check stock on Shoes three times: only its part of the page shows your message, and Mugs keeps counting. Reload shoes brings it back at 0 checks. - In
StockWidget.vue, addfunction checkLater() { setTimeout(() => check(), 500) }and a Check later button. Click it three times: the preview shows an uncaught error, the widget says checked 3 times, and the log does not change. Switch to theasyncversion: the third late check now shows the fallback and a Boundary line.