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.
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:
/** @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 usesetup()as the composition entry point. You never callsuper()or accessthis.elhere. Services are injected, state is initialized, and lifecycle hooks are registered — all insidesetup().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 fromthis.stateis tracked. Whenthis.state.loadingchanges fromtruetofalse, only the template nodes that readstate.loadingre-render. No full-component re-render.static propsfor type validation — OWL validates incoming props at runtime in dev mode. If a parent passes a string whereNumberis expected, you get a clear console error instead of a silent NaN.
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 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 <= 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:
| Feature | OWL 2 / Legacy QWeb | OWL 3 (Odoo 19) |
|---|---|---|
| Text output | t-esc="value" | t-out="value" (t-esc deprecated) |
| Event binding | t-on-click="methodName" | t-on-click="() => this.method(arg)" (arrow functions supported) |
| Loop key | Optional | t-key required for efficient reconciliation |
| Dynamic class | t-att-class | t-attf-class with interpolation or t-att-class with object |
| Sub-component | Widget instantiation | <ChildComponent propA="value"/> |
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.
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.
/** @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 insetup(). Replacing it disconnects the component from the reactive system. UseObject.assign()or mutate individual properties. - Never destructure state into local variables — primitives are copied by value.
const { counter } = this.stategives 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 readstate.nested.deep.value. - Array mutations are tracked —
push,splice,pop,shift, index assignment — all intercepted by the Proxy.
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.
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:
| Hook | When It Fires | Can Be Async | Use Case |
|---|---|---|---|
setup() | Component instantiation (before any render) | No | Inject services, initialize state, register other hooks |
onWillStart | Before first render; component waits for resolution | Yes | Fetch initial data from ORM, load configuration |
onWillRender | Before every render (initial + re-renders) | No | Compute derived values before template evaluation |
onRendered | After every render (before DOM patch) | No | Rarely needed; analytics or render performance tracking |
onMounted | After first DOM insertion | No | Initialize third-party libraries, focus inputs, start timers |
onWillUpdateProps | When parent passes new props (before re-render) | Yes | Re-fetch data when a prop changes (e.g., warehouseId) |
onWillPatch | Before a re-render patches the DOM | No | Save scroll position before DOM update |
onPatched | After a re-render patches the DOM | No | Restore scroll position, update third-party library state |
onWillUnmount | Before component is removed from DOM | No | Clear timers, remove event listeners, abort pending RPCs |
/** @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);
}
}
} 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).
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.
/** @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 Category | What It Stores | Example Usage |
|---|---|---|
services | Application-wide singleton services | Custom ORM wrappers, WebSocket managers, caching layers |
actions | Client actions (full-page components) | Custom dashboards, wizards, configuration screens |
fields | Field widget components | Custom date picker, color selector, map widget |
views | View types (list, form, kanban, etc.) | Custom calendar view, Gantt chart, floor plan |
systray | Top-bar system tray items | Custom notification bell, quick-create buttons |
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.
4 OWL 3 Mistakes That Break Odoo 19 Frontend Components in Production
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.
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.
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.
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.
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.
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.
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.
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.
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:
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.
Props validation catches type mismatches at runtime. Reactive state eliminates the "forgot to refresh the DOM" class of bugs that plague jQuery-based customizations.
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.
Optimization Metadata
Complete OWL 3 component development guide for Odoo 19. Covers component classes, template syntax, useState reactivity, lifecycle hooks, services, registries, and patching.
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"