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 componentsA 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 PropagationDeepChildthrows ErrorAncestor A — onErrorCaptured(nearest — fires first)return falsechain stopspropagatesAncestor B — onErrorCaptured(fires if A did not stop it)app.config.errorHandler(global — last in chain)

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.

onErrorCaptured return value: propagate versus stopTwo panels compare hook return values. The left panel shows that returning true, undefined, or null lets propagation continue, so the global handler fires and the same error can be logged twice. The right panel shows that returning boolean false stops the chain at the boundary and renders a local fallback.return true / undefinedpropagation continuesglobal handler fireserror may log twicereturn falsechain stops hereglobal handler skippedlocal fallback shown

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.

Error recovery flow with onErrorCaptured and key remountA four-stage vertical flow. First an error is captured in the boundary component. Then a fallback UI is shown. Then the user clicks retry, which clears the reactive error state. Finally changing the slot key forces Vue to remount the child subtree with fresh state.Error captured in boundaryShow fallback UIUser retries, clear stateKey change remounts subtree

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.
  • Errors inside the hook itself are swallowed differently. If the onErrorCaptured callback throws — say your telemetry client is misconfigured and captureException itself raises — Vue catches that secondary error and forwards it directly to app.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, 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. 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.