Catching Render Errors with the onErrorCaptured Hook
onErrorCaptured is Vue 3’s per-component equivalent of a try/catch block for an entire component subtree. Understanding its propagation semantics — specifically what return false does, when it fires relative to app.config.errorHandler, and which error sources it does and does not intercept — is essential for building reliable fallback UIs. This page covers those semantics in full, as a companion to Vue 3 Error Capturing and Fallback Strategies and the broader error interception model described in Core JavaScript Error Handling & Boundaries.
Symptom / Trigger
You add onErrorCaptured to a parent component expecting it to silence an error from a child, but the error still appears in the global handler (or in the browser console as an unhandled Vue warning). Alternatively, the opposite: you add onErrorCaptured and now the error disappears entirely from telemetry because propagation was stopped before the SDK received it.
[Vue warn]: Unhandled error during execution of render function
at <ProductCard id=42 >
at <ProductGrid >
at <App>
Error: Cannot read properties of undefined (reading 'price')
at ProductCard.vue:14
The warning names the component tree from deepest to the root, which tells you exactly which onErrorCaptured hooks were in scope but did not stop propagation.
Root Cause Explanation
Developers commonly assume that calling onErrorCaptured in a component will automatically catch errors in all descendants — and it does — but they misread what the return value controls:
// Broken assumption: returning true "catches" the error
onErrorCaptured((err) => {
logToTelemetry(err);
return true; // ← does NOT stop propagation; error continues up the chain
});
In Vue 3, return true, return undefined, return null, return 'handled', and simply not returning anything all produce the same effect: propagation continues. Only return false (the boolean, not a falsy value) stops the chain. This inverts the convention from some other frameworks where a truthy return signals “handled.”
The reason the check is strict-equality against false lives in Vue’s handleError routine: after invoking each errorCaptured hook, the runtime tests if (captured === false) return. Any other value — including a falsy 0, '', or null — fails that identity test, so the runtime keeps walking instance.parent upward until either a hook returns exactly false or the traversal exhausts every ancestor and hands the error to app.config.errorHandler. The practical consequence is the failure mode illustrated below: a hook that logs the error and returns undefined sends the same error object to both the local telemetry call and the global handler, so a naive setup double-counts every render failure in your dashboards.
A second common mistake is registering onErrorCaptured in a component that is a sibling of the failing component rather than an ancestor:
<!-- ErrorWatcher.vue — sibling, NOT ancestor of ProductCard -->
<script setup>
import { onErrorCaptured } from 'vue';
onErrorCaptured((err) => { /* never fires for sibling errors */ return false; });
</script>
onErrorCaptured only fires in ancestors (parent, grandparent, etc.) — the component tree traversal is strictly upward.
Step-by-Step Fix
1. Verify the hook is in an ancestor, not a sibling
The component registering onErrorCaptured must be a direct ancestor of the component that throws. The simplest structural check: the failing component must be rendered inside the template of the boundary component (directly or through intermediaries).
<!-- BoundaryParent.vue — correct ancestor position -->
<script setup>
import { ref, onErrorCaptured } from 'vue';
import ProductCard from './ProductCard.vue';
const capturedError = ref(null);
onErrorCaptured((err, instance, info) => {
capturedError.value = err;
// return false here — stops propagation to further ancestors and errorHandler
return false;
});
</script>
<template>
<div v-if="capturedError" role="alert">
Failed to render product: {{ capturedError.message }}
</div>
<ProductCard v-else :product="product" />
</template>
2. Return the correct value to control propagation
onErrorCaptured((err, instance, info) => {
// Log the error regardless of propagation decision
captureException(err, {
component: instance?.$.type?.__name,
vueInfo: info,
});
if (isCritical(err)) {
// Critical: let errorHandler and telemetry SDK also receive it
// Do NOT return false — propagation continues automatically
capturedError.value = err;
return; // undefined → propagates
}
// Non-critical: handle locally, suppress from global handler
capturedError.value = err;
return false; // boolean false → propagation stops
});
The three-argument signature (err, instance, info) mirrors the app.config.errorHandler signature. info identifies the Vue phase: "render function", "setup function", "watcher callback", "v-on handler", "component hook", and several others.
3. Stack multiple boundaries for layered isolation
You can register onErrorCaptured in multiple ancestors. Vue walks the tree from the direct parent of the throwing component outward. Each hook fires in order until one returns false or the chain reaches app.config.errorHandler.
<!-- OuterLayout.vue — outermost boundary for the whole page section -->
<script setup>
import { ref, onErrorCaptured } from 'vue';
const sectionError = ref(null);
onErrorCaptured((err, _instance, info) => {
// Only catch render errors — let async lifecycle errors propagate to global
if (info === 'render function') {
sectionError.value = err;
return false;
}
// For all other info types: do not return false, let it propagate
});
</script>
<!-- InnerWidget.vue — inner boundary for a specific widget -->
<script setup>
import { ref, onErrorCaptured } from 'vue';
const widgetError = ref(null);
onErrorCaptured((err) => {
widgetError.value = err;
return false; // stops here; OuterLayout.vue's hook never fires
});
</script>
This layered approach lets you handle widget-level failures locally (show a small error state in the widget) while still surfacing section-level failures to the outer boundary (show a section error banner).
4. Implement error reset to allow recovery without page reload
Because onErrorCaptured captures errors into reactive state, you can reset that state to trigger a re-render of the child subtree:
<!-- RecoverableBoundary.vue -->
<script setup>
import { ref, onErrorCaptured } from 'vue';
const props = defineProps({ resetKey: { type: Number, default: 0 } });
const error = ref(null);
let resetCount = ref(0);
onErrorCaptured((err) => {
error.value = err;
return false;
});
function retry() {
error.value = null;
resetCount.value++; // changing the key on the child forces remount
}
</script>
<template>
<div v-if="error">
<p role="alert">Component error: {{ error.message }}</p>
<button @click="retry">Retry</button>
</div>
<!-- :key change forces Vue to unmount and remount the child completely -->
<slot v-else :key="resetCount" />
</template>
Passing a changing :key to the slot forces Vue to destroy and recreate all components in the default slot, clearing any internal state that contributed to the error. The remount matters because clearing error.value alone is not enough: if the child crashed midway through setup(), its reactive refs, computed caches, and watchers may be in a half-initialised state that would immediately throw again on the next render. Changing the key guarantees a full unmount-and-mount cycle, so setup() re-runs from scratch against fresh props. The four stages below trace that recovery loop end to end.
Verification
Confirm that propagation stopping works correctly by asserting the global handler call count:
import { mount, flushPromises } from '@vue/test-utils';
import { defineComponent, h, onErrorCaptured, ref } from 'vue';
// Boundary component that returns false
const Boundary = defineComponent({
setup(_, { slots }) {
const err = ref(null);
onErrorCaptured((e) => { err.value = e; return false; });
return () => err.value ? h('div', 'caught: ' + err.value.message) : slots.default?.();
},
});
// Component that throws during render
const Thrower = defineComponent({
setup() { throw new Error('render-time error'); }
});
const globalHandler = vi.fn();
const wrapper = mount(
{ render: () => h(Boundary, null, { default: () => h(Thrower) }) },
{ global: { config: { errorHandler: globalHandler } } }
);
await flushPromises();
expect(globalHandler).not.toHaveBeenCalled(); // propagation was stopped
expect(wrapper.text()).toBe('caught: render-time error');
Edge Cases & Gotchas
onErrorCaptureddoes not fire for errors inv-onevent handlers that are NOT triggered by Vue’s rendering system. A@clickhandler runs synchronously during the click, which Vue wraps in its error handling, so it does fire. But an event handler that callssetTimeoutor spawns a Promise withoutawaitproduces an async rejection that Vue does not track.- Recursive error in the fallback template. If your fallback template itself throws (for example, referencing an undefined property on
capturedError.value), Vue will callonErrorCapturedagain on the same component, which may seterror.valueagain, creating a re-render loop. Guard fallback templates carefully: use optional chaining and safe defaults. onErrorCapturedin<Teleport>targets. Content rendered via<Teleport>remains part of the logical component tree (not the DOM tree).onErrorCapturedin a logical ancestor of the teleported content fires correctly even though the DOM is elsewhere.- Errors inside the hook itself are swallowed differently. If the
onErrorCapturedcallback throws — say your telemetry client is misconfigured andcaptureExceptionitself raises — Vue catches that secondary error and forwards it directly toapp.config.errorHandler, bypassing the rest of the ancestor chain. The original render error can be lost in that case, so keep the hook body defensive and wrap risky logging in its own try/catch. - Server-side rendering. During SSR,
onErrorCapturedfires on the server as expected. However, hydration mismatches on the client are a separate error class that may or may not route throughonErrorCaptureddepending on their severity and Vue’s recovery strategy.
FAQ
Does onErrorCaptured fire for errors thrown inside <template> expressions?
Yes. Template expressions compile to render function calls. Any error thrown while evaluating a template expression — including errors accessing properties of undefined — routes through Vue’s internal error handling and fires onErrorCaptured on ancestor components.
What does the info parameter tell me that err does not?
err is the raw JavaScript Error object. info is a Vue-specific string naming the lifecycle phase where Vue caught it: "render function", "setup function", "watcher callback", "component hook", "directive hook", and so on. This lets you differentiate between render-phase failures and data-loading failures in your telemetry dashboards without parsing stack traces.
Can I use onErrorCaptured from a composable instead of directly in a component?
Yes, provided the composable is called inside the setup() function of an ancestor component. onErrorCaptured uses Vue’s getCurrentInstance() internally to attach the hook to the currently active component. Calling it inside a composable invoked from setup() attaches it to the same component instance. The important caveat is timing: the hook must be registered synchronously during setup(). If you call your composable after an await — for example inside an async setup() that has already yielded to the microtask queue — getCurrentInstance() returns null, the registration silently no-ops, and no boundary is ever installed. Register the boundary before any awaits, then do async work afterwards, and you get a reusable useErrorBoundary() composable that returns the captured-error ref plus a retry function for every consumer.
Why does my boundary catch errors from a child but not from the boundary component’s own render?
onErrorCaptured is designed to catch errors from descendants, not from the component that registers it. If the boundary’s own template or setup() throws, Vue treats that error as originating one level up and routes it to the parent’s onErrorCaptured (or the global handler if there is none). Keep boundary components thin — a v-if fallback and a single <slot /> — so their own render surface is essentially incapable of throwing, and let the wrapped children carry the risky logic.