Ad – 728×90
🦉 Advanced OWL

OWL JS Error Handling – Boundaries, Async Errors & Recovery

Unhandled errors in OWL components crash the component that threw and propagate up the tree. Without an error boundary, a bug in one child widget can bring down the entire page. OWL provides two complementary error-handling mechanisms: onError for parent components to catch errors from their children and show fallback UI, and try/catch inside lifecycle hooks for handling expected async errors. This lesson covers both patterns, plus async error pitfalls, error recovery, and production logging strategies.

⏱️ 18 min read 🎯 Intermediate 📅 Updated 2026 👁️ Lesson 5 of 6

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.

JavaScript – error boundary component
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>
  `;
}
💡
Wrap each independent widget in its own error boundary

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:

JavaScript – try/catch in onWillStart
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>
  `;
}
Ad – 336×280

Async Error Patterns

Errors in Event Handlers

JavaScript – try/catch 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

JavaScript – retry with 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

ScenarioUseWhy
Expected network / validation errorstry/catchLocal handling — component knows how to recover
Third-party widget might crashonError boundaryIsolate crash, show fallback, don't know child internals
Async error in your own componenttry/catch in the async functionYou control the code — handle it explicitly
Rendering error (template bugs, null dereference)onError in parentCannot try/catch synchronous render errors
Plugin / dynamic widget systemonError boundaryUnknown 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/catch in 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?.message in Odoo — server-side errors include a human-readable message in data.message.

🏋️ Exercise

Build a robust RemoteWidget component with full error handling:

  1. Fetch data in onWillStart with try/catch/finally. Show loading → data → error states.
  2. Add a "Refresh" button that re-fetches (simulating a network error 50% of the time with Math.random() < 0.5).
  3. Implement withRetry on the fetch — 3 attempts with 300ms backoff.
  4. Wrap RemoteWidget in an ErrorBoundary that uses t-key="state.retryKey" on the inner child, incremented on retry — this forces a full remount of the widget.
  5. Log errors to console with timestamp and component name.

FAQ

Does onError catch errors in event handlers? +

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.

How do I force a child component to remount after an error? +

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.