A dashboard widget throws during render in Vue 3; how do you show a fallback for just that widget with `onErrorCaptured` while the rest of the page keeps working?
answer
- a wrapper one level up
- error state switches the template
- fallback must not re-render the culprit
- a new key for retry
basics
~20 sIn Vue 3, wrap each widget in a component whose onErrorCaptured stores the error in a ref and whose template then shows a fallback instead of the slot. Report the error, never re-render the failing content, and retry with a new key.
solid answer
~40 sBuild a small `WidgetBoundary` component: it renders its default slot normally, and its `onErrorCaptured` hook stores the error in a ref, so the template switches to a fallback with `v-if`. The hook has to live in the wrapper because a component never captures its own errors. Decide deliberately about propagation: return `false` only after reporting, or return nothing so `app.config.errorHandler` still reports centrally. The fallback must not render the slot that failed, or the error repeats and the component loops. A Retry button clears the error and changes a `key` on the slot so the widget mounts fresh rather than reusing half-initialised state. One gap remains: expressions evaluated in the slot content, such as a prop binding, run inside the boundary's own render, so their errors skip the boundary and go to its parent.
code
vue · 28 lines<!-- WidgetBoundary.vue -->
<script setup lang="ts">
import { ref, onErrorCaptured } from 'vue'
import { reportError } from './monitoring'
const props = defineProps<{ name: string }>()
const error = ref<unknown>(null)
const attempt = ref(0)
onErrorCaptured((err, _instance, info) => {
error.value = err
reportError(err, { widget: props.name, source: info })
return false // reported here; keep page-level boundaries and errorHandler quiet
})
function retry() {
error.value = null
attempt.value++
}
</script>
<template>
<div v-if="error" class="widget-error" role="alert">
<p>{{ name }} could not be displayed.</p>
<button type="button" @click="retry">Retry</button>
</div>
<slot v-else :key="`try-${attempt}`" />
</template>go deeper
Know that a parent component can use onErrorCaptured to swap a failing child for an error message instead of leaving a blank space.
Build the wrapper: error ref, v-if fallback, a decision about returning false, and a keyed retry.
Avoid the render loop, keep risky expressions out of slot bindings, and make sure containment never hides the error from reporting.
Decide where boundaries belong across the product — per widget, per page — so failures stay contained without multiplying fallback designs.
## What happens with no boundary When a component's render function throws, Vue catches it, sends it through the error chain, and renders an **empty placeholder** for that component. In production, with no handler, the page keeps working and the widget is simply blank — which users read as broken. In development, with no `app.config.errorHandler`, Vue rethrows, and the whole update can fail. Neither is the experience you want on a dashboard of independent widgets. ## The boundary component Vue has no built-in boundary component, but `onErrorCaptured` makes one straightforward. The wrapper: 1. renders its **default slot** while healthy; 2. registers `onErrorCaptured`, which stores the error in a `ref`; 3. switches its template with `v-if` to a **fallback** — a message and a Retry button; 4. on Retry, clears the error and changes a **`key`** so the slotted widget is unmounted and mounted fresh. The hook must be in the wrapper, not in the widget: Vue starts propagation at the failing component's **parent**, so a component's own hook never sees its own render errors. ## Propagation: stop or report? | Hook returns | Fallback shows | Ancestors' hooks | `app.config.errorHandler` | |---|---|---|---| | nothing | yes | run | runs — central reporting intact | | `false` | yes | skipped | skipped — you must report yourself | Returning `false` keeps higher-level boundaries from also switching to their fallbacks, which matters when boundaries nest (a page-level boundary around widget boundaries). If you return `false`, report inside the hook first; otherwise the error vanishes from monitoring. ## The traps - **Rendering the culprit again.** The fallback must not include the slot. If it does, the child throws again, the hook sets the error again, the boundary re-renders — an **infinite render loop**, which the Vue API docs warn about explicitly. - **Reusing broken state.** Clearing the error alone re-renders the same child instance, which may still hold whatever state caused the failure. Changing the slot's `key` forces a fresh instance and a fresh `setup()`. - **Errors in the slot's own expressions.** In `<WidgetBoundary><RevenueChart :total="stats.revenue.total" /></WidgetBoundary>`, the expression `stats.revenue.total` is evaluated when the boundary renders its slot — inside the **boundary's** render. If `stats.revenue` is undefined, the error is attributed to the boundary and starts at the boundary's parent, bypassing its own hook. Keep risky computation inside the widget component, or guard it in the page. - **Async `setup()`.** A widget whose `setup()` awaits is resolved through `<Suspense>`, and errors from async setup come with their own caveats; that belongs to Suspense. - **Async hooks.** An `async` `onErrorCaptured` returns a promise, never `false`; keep the hook synchronous. ## What to report The hook receives `(err, instance, info)`. Record the widget's name (pass it as a prop to the boundary), `info` (for example `'render function'`, or a production code), and the error. That is usually enough to find which widget and which phase failed, without which a dashboard of twelve widgets produces anonymous stack traces. ## Letting the page choose the fallback A hard-coded fallback message is fine for one dashboard; a reusable boundary usually lets the caller supply it through a named slot, passing the error and the retry function as slot props: ```vue-html <WidgetBoundary name="Revenue"> <RevenueChart /> <template #fallback="{ retry }"> <p>Revenue is unavailable.</p> <button type="button" @click="retry">Try again</button> </template> </WidgetBoundary> ``` Inside the boundary, `<slot name="fallback" :error="error" :retry="retry">` renders it, with the built-in message as that slot's default content. The rule about never re-rendering the failing default slot still applies. ## Why not one boundary for the whole page? A single top-level boundary turns any widget failure into a whole-page fallback. Wrapping **each independent widget** keeps the blast radius to one tile, which is the point of the pattern on a dashboard. Keep a page-level boundary or the app handler as the outer net for failures outside the widgets.
- Why is `<WidgetBoundary><RevenueChart :total="stats.revenue.total" /></WidgetBoundary>` not protected when `stats.revenue` is undefined?Slot content is compiled into a function that the boundary calls while rendering its `<slot>`, so the prop expression is evaluated during the boundary's own render. The error is therefore attributed to the boundary, and Vue starts propagation at the boundary's parent, skipping its own `onErrorCaptured`. Move the computation into `RevenueChart`, or guard it in the page.
- Why does Retry change a `key` instead of only clearing the error ref?Clearing the ref alone makes the boundary render its slot again, and Vue patches the existing child instance, which may still hold the state that caused the failure. A new `key` makes Vue unmount the old instance and mount a new one, running `setup()` and fetching data afresh — a real retry rather than a re-render of the same broken component.
saying these in an interview costs you the question
- A widget can catch its own render errors with its own onErrorCaptured.
- Showing the slot beside the error message is a safe fallback.
- Returning false still lets app.config.errorHandler report the error.
- Clearing the error ref is enough to retry with fresh state.
- The boundary also catches errors in its slot content's prop expressions.