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.

onErrorCaptured propagation chain across ancestor components A deep component tree with three ancestor components each registering onErrorCaptured. An error thrown in the deepest child travels upward. The nearest ancestor fires first. If it returns false, no further ancestors or the global handler are called. If it returns anything else the next ancestor fires, and so on up to app.config.errorHandler. onErrorCaptured Propagation DeepChild throws Error Ancestor A — onErrorCaptured (nearest to thrower — fires first) return false chain stops here propagates Ancestor B — onErrorCaptured (fires only if A did not return false) app.config.errorHandler (global — fires if no ancestor returned false)

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).




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:




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

  • onErrorCaptured does not fire for errors in v-on event handlers that are NOT triggered by Vue’s rendering system. A @click handler runs synchronously during the click, which Vue wraps in its error handling, so it does fire. But an event handler that calls setTimeout or spawns a Promise without await produces 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 call onErrorCaptured again on the same component, which may set error.value again, creating a re-render loop. Guard fallback templates carefully: use optional chaining and safe defaults.
  • onErrorCaptured in <Teleport> targets. Content rendered via <Teleport> remains part of the logical component tree (not the DOM tree). onErrorCaptured in a logical ancestor of the teleported content fires correctly even though the DOM is elsewhere.
  • Server-side rendering. During SSR, onErrorCaptured fires on the server as expected. However, hydration mismatches on the client are a separate error class that may or may not route through onErrorCaptured depending 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.