onError — Error Boundary Components
onError registers a catch handler on a component. Any error thrown by a descendant component's render, lifecycle hook, or async operation is caught here. The catching component can then show fallback UI instead of crashing.
import { Component, xml, useState, onError } from "@odoo/owl";
class ErrorBoundary extends Component {
static template = xml`
<t t-if="state.error">
<div class="error-boundary">
<h3>⚠️ Something went wrong</h3>
<p t-esc="state.errorMessage"/>
<details t-if="state.errorStack">
<summary>Technical details</summary>
<pre t-esc="state.errorStack"/>
</details>
<button t-on-click="retry">Try Again</button>
</div>
</t>
<t t-else="">
<t t-slot="default"/>
</t>
`;
state = useState({ error: false, errorMessage: "", errorStack: "" });
setup() {
onError((error) => {
console.error("[ErrorBoundary] Caught:", error);
this.state.error = true;
this.state.errorMessage = error?.message || "An unexpected error occurred.";
this.state.errorStack = error?.stack || "";
// Optionally: report to error tracking service
// logToSentry(error);
});
}
retry() {
// Reset the error state to attempt re-rendering the children
this.state.error = false;
this.state.errorMessage = "";
this.state.errorStack = "";
// Note: the child component needs a fresh key to truly remount
}
}
// Usage — wrap risky widgets
class Dashboard extends Component {
static components = { ErrorBoundary, SalesSummary, RevenueChart };
static template = xml`
<div class="dashboard">
<ErrorBoundary>
<SalesSummary/>
</ErrorBoundary>
<ErrorBoundary>
<RevenueChart/>
</ErrorBoundary>
</div>
`;
}
Wrapping the entire page in one error boundary means any error kills the whole page. Wrapping each independent widget in its own boundary keeps failures isolated — one broken chart does not crash the sales summary next to it. This is the Odoo pattern: each view section has independent error recovery.
try/catch in Lifecycle Hooks
For expected errors — network failures, validation errors, 404s — handle them locally with try/catch in the relevant hook:
class PartnerWidget extends Component {
state = useState({
partner: null,
isLoading: true,
error: null,
});
setup() {
this.orm = useService("orm");
onWillStart(async () => {
try {
const [partner] = await this.orm.read(
"res.partner",
[this.props.partnerId],
["name", "email", "phone", "image_128"]
);
this.state.partner = partner;
} catch (err) {
// Handle locally — no need for an error boundary
this.state.error = err.message || "Failed to load partner.";
} finally {
this.state.isLoading = false;
}
});
}
static template = xml`
<div>
<div t-if="state.isLoading">Loading…</div>
<div t-elif="state.error" class="alert-danger" t-esc="state.error"/>
<div t-else="">
<strong t-esc="state.partner.name"/>
<span t-esc="state.partner.email"/>
</div>
</div>
`;
}
Async Error Patterns
Errors in Event Handlers
class SaveButton extends Component {
state = useState({ isSaving: false, error: null });
static template = xml`
<div>
<button t-att-disabled="state.isSaving" t-on-click="save">
<t t-if="state.isSaving">Saving…</t>
<t t-else="">Save</t>
</button>
<p t-if="state.error" class="text-danger" t-esc="state.error"/>
</div>
`;
async save() {
this.state.isSaving = true;
this.state.error = null;
try {
await this.props.onSave();
this.notification.add("Saved!", { type: "success" });
} catch (err) {
// UserError from Odoo server has a human-readable message
this.state.error = err?.data?.message || err.message || "Save failed.";
} finally {
this.state.isSaving = false;
}
}
}
Retryable Errors with Exponential Backoff
async function withRetry(fn, { retries = 3, delay = 500 } = {}) {
for (let attempt = 0; attempt < retries; attempt++) {
try {
return await fn();
} catch (err) {
if (attempt === retries - 1) throw err; // last attempt — rethrow
await new Promise(r => setTimeout(r, delay * (attempt + 1))); // backoff
}
}
}
// Usage in onWillStart:
onWillStart(async () => {
try {
this.state.data = await withRetry(
() => this.orm.searchRead("sale.order", [], ["name"], { limit: 10 }),
{ retries: 3, delay: 300 }
);
} catch (err) {
this.state.error = "Could not load after 3 attempts. Please refresh.";
}
});
When to Use onError vs try/catch
| Scenario | Use | Why |
|---|---|---|
| Expected network / validation errors | try/catch | Local handling — component knows how to recover |
| Third-party widget might crash | onError boundary | Isolate crash, show fallback, don't know child internals |
| Async error in your own component | try/catch in the async function | You control the code — handle it explicitly |
| Rendering error (template bugs, null dereference) | onError in parent | Cannot try/catch synchronous render errors |
| Plugin / dynamic widget system | onError boundary | Unknown plugin code should not crash the host |
Error Boundaries: Stop One Broken Component Killing the App
By default, an error thrown while rendering a component can crash the whole UI. OWL's onError hook creates an error boundary — a component that catches errors from its children and shows a fallback instead of a blank page.
import { Component, useState, onError, xml } from "@odoo/owl";
class ErrorBoundary extends Component {
static template = xml`
<t t-if="state.failed">Something went wrong.</t>
<t t-else=""><t t-slot="default"/></t>`; // render children
setup() {
this.state = useState({ failed: false });
onError((error) => { // catches errors from child components
this.state.failed = true;
console.error(error); // log for debugging
});
}
}
Wrap risky parts of the tree in the boundary; the rest of the app keeps working:
xml`<ErrorBoundary><RiskyChart/></ErrorBoundary>`;
What to know: onError catches errors from rendering and lifecycle hooks of descendant components — not from event handlers or async code you fire yourself (wrap those in your own try/catch). Place boundaries around independent sections (a dashboard widget, a sidebar) so one failing piece degrades gracefully instead of taking down everything. Always log the caught error — a silent fallback hides real bugs.
🏋️ Practical Exercise
Build a robust RemoteWidget component with full error handling:
- Fetch data in
onWillStartwithtry/catch/finally. Show loading → data → error states. - Add a "Refresh" button that re-fetches (simulating a network error 50% of the time with
Math.random() < 0.5). - Implement
withRetryon the fetch — 3 attempts with 300ms backoff. - Wrap
RemoteWidgetin anErrorBoundarythat usest-key="state.retryKey"on the inner child, incremented on retry — this forces a full remount of the widget. - Log errors to console with timestamp and component name.
🔥 Challenge Exercise
Build an error-boundary component using onError that catches errors from children and renders a fallback UI instead of crashing the app. Explain how OWL propagates errors and where to place a boundary.
📋 Summary
onError(callback)makes a component an error boundary — it catches errors from all descendants including render errors, lifecycle errors, and async rejections.- Show fallback UI in the error state and log the error for debugging. Provide a "Retry" button when recovery is possible.
- Wrap each independent widget section in its own error boundary to isolate failures.
- Use
try/catchin lifecycle hooks and event handlers for expected errors you know how to recover from. - Always guard async operations with try/catch and set a loading/error state in
finally. - Check for
err?.data?.messagein Odoo — server-side errors include a human-readable message indata.message.
Interview Questions
- How do you handle errors in OWL?
- What is the
onErrorhook? - What is an error boundary?
- What happens to an unhandled component error?
- Where should you place error boundaries?
Related Topics
FAQ
Yes, if the event handler throws synchronously. For async handlers, only rejected Promises that OWL is aware of are caught. If an async event handler throws after the current render cycle, the rejection may be unhandled. Always use try/catch in async event handlers and set error state explicitly rather than relying on onError to catch async rejections.
Change the child's t-key value. OWL treats a different t-key as a completely new component instance — it unmounts the old one and mounts a fresh one. In the error boundary's retry method, increment a retryKey state value: this.state.retryKey++. The template uses <RiskyChild t-key="state.retryKey"/>. The new instance runs onWillStart fresh.

