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 |
📋 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.
🏋️ 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.
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.