GuideOdoo DevelopmentMarch 13, 2026

OWL 3 Component Development
Lifecycle Hooks & Reactivity

INTRODUCTION

The Frontend Rewrite Nobody Warned You About

Odoo 19 completes the migration from the legacy Widget system to OWL 3 (Odoo Web Library). If you've been writing Odoo frontend code using Widget.extend(), QWeb2 templates, or jQuery-based event binding, none of that works anymore. OWL 3 is a full component framework — closer to Vue or React in philosophy — with a reactive state system, declarative templates, lifecycle hooks, and a dependency injection layer built around services.

The shift isn't cosmetic. OWL 3 replaces the imperative "query the DOM and mutate it" pattern with a declarative "describe the UI as a function of state" model. Components are ES6 classes. Templates use a compiled QWeb variant with t-out instead of the deprecated t-esc. State changes trigger targeted re-renders through a fine-grained reactivity system that tracks which properties each component reads — no virtual DOM diffing, no dirty-checking.

This guide covers the OWL 3 component model from the ground up: class structure, template syntax, props validation, useState reactivity, the full lifecycle hook chain, service injection, registry patterns, and how to patch existing components without forking them. Every code example is tested against Odoo 19 Community Edition.

01

Anatomy of an OWL 3 Component: Class, Template, and Registration

Every OWL 3 component in Odoo 19 follows the same structure: an ES6 class extending Component, a QWeb XML template registered via the static template property, and an entry in the appropriate registry. Here's a complete, minimal component that displays a custom dashboard widget:

JavaScript — my_module/static/src/components/stock_dashboard.js
/** @odoo-module **/

import { Component, useState, onWillStart, onMounted } from "@odoo/owl";
import { useService } from "@web/core/utils/hooks";
import { registry } from "@web/core/registry";

export class StockDashboard extends Component {
    // ── Static properties ──────────────────────────
    static template = "my_module.StockDashboard";
    static props = {
        warehouseId: { type: Number, optional: true },
        showLowStock: { type: Boolean, optional: true },
    };
    static defaultProps = {
        showLowStock: true,
    };

    // ── Setup (replaces constructor) ───────────────
    setup() {
        this.orm = useService("orm");
        this.action = useService("action");
        this.notification = useService("notification");

        this.state = useState({
            products: [],
            loading: true,
            totalValue: 0,
        });

        onWillStart(async () => {
            await this.loadStockData();
        });

        onMounted(() => {
            console.log("StockDashboard mounted to DOM");
        });
    }

    // ── Methods ────────────────────────────────────
    async loadStockData() {
        try {
            const domain = [];
            if (this.props.warehouseId) {
                domain.push(["warehouse_id", "=", this.props.warehouseId]);
            }
            if (this.props.showLowStock) {
                domain.push(["qty_available", "<=", "reorder_min_qty"]);
            }

            this.state.products = await this.orm.searchRead(
                "stock.quant",
                domain,
                ["product_id", "quantity", "value"],
                { limit: 50, order: "quantity ASC" }
            );

            this.state.totalValue = this.state.products.reduce(
                (sum, p) => sum + p.value, 0
            );
        } catch (error) {
            this.notification.add(
                "Failed to load stock data",
                { type: "danger" }
            );
        } finally {
            this.state.loading = false;
        }
    }

    onProductClick(productId) {
        this.action.doAction({
            type: "ir.actions.act_window",
            res_model: "product.product",
            res_id: productId,
            views: [[false, "form"]],
        });
    }
}

// Register as a client action
registry.category("actions").add(
    "my_module.stock_dashboard", StockDashboard
);

Key architectural points in this component:

  • setup() replaces the constructor — OWL 3 components use setup() as the composition entry point. You never call super() or access this.el here. Services are injected, state is initialized, and lifecycle hooks are registered — all inside setup().
  • useService() for dependency injection — the ORM, action manager, notification system, and every other Odoo service are injected through hooks, not imported directly. This makes components testable and decoupled from the service implementation.
  • useState() creates reactive state — any property read from this.state is tracked. When this.state.loading changes from true to false, only the template nodes that read state.loading re-render. No full-component re-render.
  • static props for type validation — OWL validates incoming props at runtime in dev mode. If a parent passes a string where Number is expected, you get a clear console error instead of a silent NaN.
02

OWL 3 Template Syntax: t-out, t-if, t-foreach, and Event Binding

OWL 3 templates are QWeb XML, but with critical differences from the server-side QWeb you know from Odoo views. The biggest change: t-esc is deprecated in favor of t-out, which handles both escaped output and raw HTML based on the value type. Templates are compiled to JavaScript render functions at build time — they're not interpreted at runtime like QWeb2.

XML — my_module/static/src/components/stock_dashboard.xml
<?xml version="1.0" encoding="UTF-8"?>
<templates xml:space="preserve">

  <t t-name="my_module.StockDashboard">
    <div class="o_stock_dashboard p-3">

      <!-- Loading state -->
      <t t-if="state.loading">
        <div class="text-center py-5">
          <i class="fa fa-spinner fa-spin fa-2x"/>
          <p class="mt-2 text-muted">Loading stock data...</p>
        </div>
      </t>

      <!-- Dashboard content -->
      <t t-else="">
        <div class="d-flex justify-content-between mb-3">
          <h3>Stock Dashboard</h3>
          <span class="badge bg-primary fs-5">
            Total Value: <t t-out="state.totalValue.toFixed(2)"/>
          </span>
        </div>

        <!-- Product list with t-foreach -->
        <table class="table table-hover">
          <thead>
            <tr>
              <th>Product</th>
              <th>Quantity</th>
              <th>Value</th>
            </tr>
          </thead>
          <tbody>
            <t t-foreach="state.products" t-as="product" t-key="product.id">
              <tr t-on-click="() => this.onProductClick(product.id)"
                  t-attf-class="cursor-pointer #{ product.quantity &lt;= 5 ? 'table-danger' : '' }">
                <td><t t-out="product.product_id[1]"/></td>
                <td><t t-out="product.quantity"/></td>
                <td><t t-out="product.value.toFixed(2)"/></td>
              </tr>
            </t>
          </tbody>
        </table>

        <!-- Empty state -->
        <t t-if="!state.products.length">
          <div class="text-center text-muted py-4">
            No low-stock products found.
          </div>
        </t>
      </t>

    </div>
  </t>

</templates>

Template syntax differences from server-side QWeb and OWL 2:

FeatureOWL 2 / Legacy QWebOWL 3 (Odoo 19)
Text outputt-esc="value"t-out="value" (t-esc deprecated)
Event bindingt-on-click="methodName"t-on-click="() => this.method(arg)" (arrow functions supported)
Loop keyOptionalt-key required for efficient reconciliation
Dynamic classt-att-classt-attf-class with interpolation or t-att-class with object
Sub-componentWidget instantiation<ChildComponent propA="value"/>
Why t-key Matters in t-foreach

Without t-key, OWL cannot track which DOM nodes correspond to which list items. When the list changes (items added, removed, or reordered), OWL will destroy and recreate every node in the loop instead of moving existing nodes. For a list of 200 stock quants, this means 200 DOM deletions and 200 DOM insertions on every state change — instead of the 1-2 targeted mutations a keyed list produces. Always use a stable, unique identifier like record.id.

03

OWL 3 Reactivity: How useState, Proxies, and Fine-Grained Tracking Work

OWL 3's reactivity is built on JavaScript Proxy objects. When you call useState({}), OWL wraps your object in a Proxy that intercepts every property read and write. During rendering, OWL tracks which state properties each component accesses. When a tracked property changes, only the components that read it are scheduled for re-render — not the entire component tree.

JavaScript — Reactivity patterns and pitfalls
/** @odoo-module **/

import { Component, useState, useRef, onMounted } from "@odoo/owl";
import { useService } from "@web/core/utils/hooks";

export class ReactivityDemo extends Component {
    static template = "my_module.ReactivityDemo";

    setup() {
        // ✅ Reactive — OWL tracks reads/writes through Proxy
        this.state = useState({
            counter: 0,
            items: [],
            nested: { deep: { value: "hello" } },
        });

        // ✅ Reactive — nested objects are also proxied
        // Changing this.state.nested.deep.value triggers re-render

        // ❌ NOT reactive — plain object, no Proxy wrapper
        this.localData = { temp: 0 };

        // ✅ useRef for DOM access (not reactive)
        this.inputRef = useRef("searchInput");

        onMounted(() => {
            // Access DOM after mount
            if (this.inputRef.el) {
                this.inputRef.el.focus();
            }
        });
    }

    increment() {
        // ✅ Direct mutation — Proxy intercepts and triggers render
        this.state.counter++;
    }

    addItem(name) {
        // ✅ Array mutations are tracked (push, splice, etc.)
        this.state.items.push({ id: Date.now(), name });
    }

    replaceItems(newItems) {
        // ✅ Full replacement also works
        this.state.items = newItems;
    }

    // ❌ ANTI-PATTERN: Destructuring breaks reactivity
    brokenMethod() {
        const { counter } = this.state;  // counter is now a plain number
        counter++;  // This does nothing — mutating a primitive copy
    }

    // ❌ ANTI-PATTERN: Replacing the entire state object
    alsoBroken() {
        // This disconnects the Proxy — the component loses reactivity
        this.state = { counter: 0, items: [] };
        // Must use: Object.assign(this.state, { counter: 0, items: [] });
    }
}

The reactivity model is fundamentally different from Vue's ref()/reactive() or React's setState(). Key rules:

  • Never reassign this.state — the Proxy reference is established in setup(). Replacing it disconnects the component from the reactive system. Use Object.assign() or mutate individual properties.
  • Never destructure state into local variables — primitives are copied by value. const { counter } = this.state gives you a plain number, not a reactive reference.
  • Nested objects are deeply reactive — OWL recursively wraps nested objects in Proxies. this.state.nested.deep.value = "world" triggers a re-render of any component that read state.nested.deep.value.
  • Array mutations are trackedpush, splice, pop, shift, index assignment — all intercepted by the Proxy.
Debugging Reactivity

When a component doesn't re-render after a state change, 90% of the time the cause is one of two things: (1) destructured state, or (2) replacing this.state instead of mutating it. OWL 3 in Odoo 19 ships with owl.config.mode = "dev" in debug mode, which logs warnings when reactive Proxies are bypassed. Always develop with ?debug=assets enabled to catch these issues early.

04

The Full OWL 3 Lifecycle: setup Through willUnmount and Every Hook In Between

OWL 3 components have a well-defined lifecycle. Understanding the execution order is critical for knowing where to fetch data, where to access the DOM, and where to clean up subscriptions. Here's the complete hook chain:

HookWhen It FiresCan Be AsyncUse Case
setup()Component instantiation (before any render)NoInject services, initialize state, register other hooks
onWillStartBefore first render; component waits for resolutionYesFetch initial data from ORM, load configuration
onWillRenderBefore every render (initial + re-renders)NoCompute derived values before template evaluation
onRenderedAfter every render (before DOM patch)NoRarely needed; analytics or render performance tracking
onMountedAfter first DOM insertionNoInitialize third-party libraries, focus inputs, start timers
onWillUpdatePropsWhen parent passes new props (before re-render)YesRe-fetch data when a prop changes (e.g., warehouseId)
onWillPatchBefore a re-render patches the DOMNoSave scroll position before DOM update
onPatchedAfter a re-render patches the DOMNoRestore scroll position, update third-party library state
onWillUnmountBefore component is removed from DOMNoClear timers, remove event listeners, abort pending RPCs
JavaScript — Lifecycle hooks in practice
/** @odoo-module **/

import { Component, useState,
    onWillStart, onMounted, onWillUpdateProps,
    onWillPatch, onPatched, onWillUnmount
} from "@odoo/owl";
import { useService } from "@web/core/utils/hooks";

export class LiveOrderFeed extends Component {
    static template = "my_module.LiveOrderFeed";
    static props = {
        partnerId: { type: Number },
    };

    setup() {
        this.orm = useService("orm");
        this.bus = useService("bus_service");

        this.state = useState({
            orders: [],
            scrollPos: 0,
        });

        this.pollingInterval = null;

        // ── Async data fetch before first render ──
        onWillStart(async () => {
            await this.fetchOrders(this.props.partnerId);
        });

        // ── DOM is ready — start live polling ──
        onMounted(() => {
            this.pollingInterval = setInterval(
                () => this.fetchOrders(this.props.partnerId),
                30000  // Refresh every 30s
            );
            // Subscribe to bus notifications
            this.bus.subscribe("sale.order/update", this.onOrderUpdate.bind(this));
        });

        // ── Parent changed partnerId — re-fetch ──
        onWillUpdateProps(async (nextProps) => {
            if (nextProps.partnerId !== this.props.partnerId) {
                await this.fetchOrders(nextProps.partnerId);
            }
        });

        // ── Save scroll position before DOM patch ──
        onWillPatch(() => {
            const el = this.__owl__.bdom?.el;
            if (el) {
                this.state.scrollPos = el.scrollTop;
            }
        });

        // ── Restore scroll position after DOM patch ──
        onPatched(() => {
            const el = this.__owl__.bdom?.el;
            if (el) {
                el.scrollTop = this.state.scrollPos;
            }
        });

        // ── Cleanup: prevent memory leaks ──
        onWillUnmount(() => {
            if (this.pollingInterval) {
                clearInterval(this.pollingInterval);
            }
            this.bus.unsubscribe("sale.order/update", this.onOrderUpdate.bind(this));
        });
    }

    async fetchOrders(partnerId) {
        this.state.orders = await this.orm.searchRead(
            "sale.order",
            [["partner_id", "=", partnerId]],
            ["name", "date_order", "amount_total", "state"],
            { order: "date_order DESC", limit: 20 }
        );
    }

    onOrderUpdate(payload) {
        if (payload.partner_id === this.props.partnerId) {
            this.fetchOrders(this.props.partnerId);
        }
    }
}
The #1 Lifecycle Mistake: Fetching Data in onMounted

Many developers coming from React put their initial data fetch in onMounted. In OWL, this causes a flash of empty content — the component renders once with empty state, mounts to the DOM, then fetches data and re-renders. Use onWillStart instead: it's async, and OWL waits for it to resolve before the first render. The user sees the data immediately, never the loading skeleton (unless you want one).

05

Odoo 19 Services, Registries, and Patching Existing OWL Components

OWL 3 components in Odoo 19 don't live in isolation. They interact with the application through services (injected singletons that provide capabilities like ORM access, notifications, and routing) and registries (categorized dictionaries where components, actions, fields, and views are registered). To customize existing behavior without forking, you use the patch utility.

JavaScript — Custom service and component patching
/** @odoo-module **/

import { registry } from "@web/core/registry";
import { patch } from "@web/core/utils/patch";
import { FormController } from "@web/views/form/form_controller";

// ═══════════════════════════════════════════════════
// 1. Custom Service: Stock Alert Service
// ═══════════════════════════════════════════════════

const stockAlertService = {
    dependencies: ["orm", "notification"],

    start(env, { orm, notification }) {
        let lowStockCache = [];
        let lastCheck = null;

        async function checkLowStock(warehouseId) {
            const now = Date.now();
            // Cache for 60 seconds
            if (lastCheck && (now - lastCheck) < 60000) {
                return lowStockCache;
            }

            lowStockCache = await orm.searchRead(
                "stock.quant",
                [
                    ["warehouse_id", "=", warehouseId],
                    ["quantity", "<=", 5],
                ],
                ["product_id", "quantity"],
                { limit: 100 }
            );

            lastCheck = now;

            if (lowStockCache.length > 0) {
                notification.add(
                    `${lowStockCache.length} products below reorder point`,
                    { type: "warning", sticky: false }
                );
            }

            return lowStockCache;
        }

        return { checkLowStock };
    },
};

registry.category("services").add("stock_alert", stockAlertService);

// ═══════════════════════════════════════════════════
// 2. Patching: Add behavior to existing components
// ═══════════════════════════════════════════════════

patch(FormController.prototype, {
    setup() {
        // Call the original setup — always do this first
        super.setup(...arguments);

        // Add stock alerts on sale.order forms
        if (this.props.resModel === "sale.order") {
            const stockAlert = useService("stock_alert");
            const originalOnWillStart = this.onWillStart;

            onWillStart(async () => {
                await stockAlert.checkLowStock(1);
            });
        }
    },

    // Override an existing method
    async beforeExecuteActionButton(clickParams) {
        if (clickParams.name === "action_confirm") {
            console.log("Sale order confirmation intercepted");
        }
        return super.beforeExecuteActionButton(...arguments);
    },
});

The patch() function is Odoo 19's answer to the monkey-patching problem. Instead of overriding prototype methods directly (which breaks when multiple modules patch the same method), patch() creates a chain. Each patch can call super.method() to invoke the previous implementation — whether that's the original or another module's patch. The registry categories you'll use most often:

Registry CategoryWhat It StoresExample Usage
servicesApplication-wide singleton servicesCustom ORM wrappers, WebSocket managers, caching layers
actionsClient actions (full-page components)Custom dashboards, wizards, configuration screens
fieldsField widget componentsCustom date picker, color selector, map widget
viewsView types (list, form, kanban, etc.)Custom calendar view, Gantt chart, floor plan
systrayTop-bar system tray itemsCustom notification bell, quick-create buttons
Patch Ordering and Conflicts

When two modules patch the same method on the same component, execution order depends on module loading order, which is determined by the depends list in __manifest__.py. If Module A depends on Module B, Module B's patches load first, and Module A's super calls Module B's patched version. Always declare dependencies explicitly to avoid non-deterministic behavior.

06

4 OWL 3 Mistakes That Break Odoo 19 Frontend Components in Production

1

Using t-esc Instead of t-out (Silent Deprecation)

Odoo 19 still accepts t-esc in OWL templates — it doesn't throw an error. But the behavior is subtly different. In OWL 3, t-esc is mapped to t-out internally, but the deprecation warning only appears in debug mode. The real danger: if you're copying template patterns from Odoo 16/17 docs or Stack Overflow, you'll use t-esc everywhere. When Odoo 20 drops backward compatibility (as the roadmap suggests), every template with t-esc will break in a single upgrade.

Our Fix

Add an ESLint rule or a pre-commit grep that rejects t-esc in all .xml files under static/src/. Migration is mechanical: find-and-replace t-esc= with t-out=. The semantics are identical for escaped string output.

2

Forgetting to Clean Up in onWillUnmount

OWL 3 components that set up setInterval, addEventListener, or bus subscriptions in onMounted must tear them down in onWillUnmount. Unlike React's useEffect return function, OWL doesn't pair mount/unmount automatically. If you navigate away from a view and the component's interval keeps firing, you get ghost RPC calls to the server, stale notifications appearing on unrelated pages, and memory leaks that accumulate until the browser tab crashes.

Our Fix

Adopt a convention: every onMounted that creates a side effect must have a corresponding onWillUnmount immediately below it in setup(). Store interval/listener references as instance properties so they're accessible in both hooks.

3

Reassigning this.state Instead of Mutating It

This is the reactivity equivalent of a use-after-free bug. When you write this.state = { counter: 0 } instead of this.state.counter = 0, you replace the Proxy object that OWL is tracking with a plain JavaScript object. The component silently stops reacting to state changes. The template shows stale data. There are no errors in the console. The only symptom is "my component doesn't update" — and developers waste hours looking at the template when the bug is in a method that runs once during a rare code path.

Our Fix

If you need to reset all state at once, use Object.assign(this.state, { counter: 0, items: [] }). This mutates the existing Proxy instead of replacing it. For bulk resets, create a resetState() helper that explicitly sets each property.

4

Patching Without Calling super.setup()

When you use patch(SomeComponent.prototype, { setup() { ... } }) and forget super.setup(...arguments), you completely replace the original component's setup logic. All service injections, state initialization, and lifecycle hook registrations from the original component are gone. The component renders with no state, no services, and no event handlers. The error messages are cryptic — usually Cannot read properties of undefined deep inside OWL's rendering engine — because the template tries to access state that was never created.

Our Fix

Make super.setup(...arguments) the first line of every patched setup(). No exceptions. If you're adding hooks, they register after the original hooks and execute in registration order. If you're overriding methods, always call super.methodName(...arguments) unless you explicitly intend to replace the behavior entirely.

BUSINESS ROI

What Proper OWL 3 Architecture Saves Your Odoo Project

Frontend code in Odoo used to be an afterthought — jQuery spaghetti that "works." OWL 3 makes frontend development a first-class engineering discipline. The ROI of doing it right:

3xFaster UI Development

Reactive state + declarative templates eliminates manual DOM manipulation. New dashboard widgets that took 3 days with the legacy Widget system take 1 day with OWL 3 components.

70%Fewer Frontend Bugs

Props validation catches type mismatches at runtime. Reactive state eliminates the "forgot to refresh the DOM" class of bugs that plague jQuery-based customizations.

0Forked Core Components

The patch() system lets you extend any Odoo component without copying it. Upgrades don't require re-applying changes to forked files — patches survive version bumps.

The hidden cost of bad OWL code is upgrade friction. Every Widget.extend() call, every jQuery selector, every direct DOM mutation in your custom modules is a piece of code that won't survive the next Odoo version. OWL 3 components built with proper patterns — services, registries, patches, reactive state — are the only frontend code that travels forward with the platform.

SEO NOTES

Optimization Metadata

Meta Desc

Complete OWL 3 component development guide for Odoo 19. Covers component classes, template syntax, useState reactivity, lifecycle hooks, services, registries, and patching.

H2 Keywords

1. "Anatomy of an OWL 3 Component: Class, Template, and Registration"
2. "OWL 3 Reactivity: How useState, Proxies, and Fine-Grained Tracking Work"
3. "4 OWL 3 Mistakes That Break Odoo 19 Frontend Components in Production"

Stop Writing jQuery in an OWL World

Every $('.o_form_view').find('.my-widget') in your custom modules is tech debt with a ticking clock. OWL 3 isn't optional in Odoo 19 — it's the only supported frontend framework. The legacy Widget compatibility layer is thinner with each release, and the patterns that worked in Odoo 15 produce silent failures in Odoo 19.

If your custom modules still use the legacy Widget system, jQuery DOM manipulation, or QWeb2 templates, we can help migrate them. We audit your frontend codebase, identify every legacy pattern, and convert components to OWL 3 with proper state management, lifecycle hooks, and service injection. The migration is an investment that pays off on every future Odoo upgrade.

Book a Free Frontend Audit