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.”
A second common mistake is registering onErrorCaptured in a component that is a sibling of the failing component rather than an ancestor:
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).
Failed to render product: {{ capturedError.message }}
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.
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:
Component error: {{ error.message }}
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.
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.- 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.