OWL (Odoo Web Library) replaced the legacy Backbone/Widget system in Odoo 14 and became the exclusive frontend framework in Odoo 16. If you are writing any Odoo JavaScript - a custom field widget, a dashboard button, a kanban progress bar - you are writing OWL.
This guide covers everything you need to build production-quality OWL components: the component model, reactive state, hooks, service injection, and how to slot your component into Odoo's view system without monkey-patching.
OWL fundamentals#
OWL is a standalone reactive UI library similar in spirit to Vue or Svelte. Odoo bundles it but OWL itself has no Odoo dependency - you can read its documentation at owl.odoo.com independently.
Component class structure#
/** @odoo-module **/
import { Component, useState, onMounted, onWillDestroy } from "@odoo/owl";
import { registry } from "@web/core/registry";
export class MyProgressBar extends Component {
static template = "my_module.ProgressBar";
static props = {
value: { type: Number },
max: { type: Number, optional: true },
label: { type: String, optional: true },
};
static defaultProps = {
max: 100,
label: "",
};
setup() {
this.state = useState({ hovered: false });
}
get percentage() {
return Math.min(100, (this.props.value / this.props.max) * 100);
}
onMouseEnter() {
this.state.hovered = true;
}
onMouseLeave() {
this.state.hovered = false;
}
}static template names the QWeb XML template. Always use the "module_name.TemplateName" convention.
static props declares the expected props with type validation. OWL throws in dev mode if a prop is missing or wrong type. optional: true allows the prop to be omitted. Use this - it documents the component interface and catches bugs early.
setup() is the initialisation method (analogous to Vue's setup() or React's constructor). Do not use a constructor. Put all hook calls here.
QWeb template#
Templates live in XML files loaded by Odoo's asset system:
<?xml version="1.0" encoding="UTF-8" ?>
<templates xml:space="preserve">
<t t-name="my_module.ProgressBar">
<div class="my-progress-bar" t-att-class="{ hovered: state.hovered }"
t-on-mouseenter="onMouseEnter" t-on-mouseleave="onMouseLeave">
<div class="bar" t-att-style="'width: ' + percentage + '%'"/>
<span t-if="props.label" t-esc="props.label"/>
</div>
</t>
</templates>Key template directives:
| Directive | Purpose |
|---|---|
t-esc | Output escaped text content |
t-out | Output raw HTML (use carefully) |
t-if / t-elif / t-else | Conditional rendering |
t-foreach / t-as | Loop rendering |
t-on- | Event listener |
t-att- | Dynamic attribute binding |
t-att-class | Class object binding ({ className: condition }) |
t-component | Render a child component |
t-props | Pass props object to child component |
t-slot / t-set-slot | Named slot insertion |
Reactive state with useState#
useState() wraps a plain object in a Proxy. Any property assignment triggers a re-render of the affected component tree.
setup() {
this.state = useState({
loading: false,
items: [],
selected: null,
});
}
async loadItems() {
this.state.loading = true;
const result = await this.rpc("/my_module/get_items");
this.state.items = result;
this.state.loading = false;
}Rules for useState:
- Call it only inside
setup()(or a custom hook called fromsetup()) - Nest objects work - OWL proxies deeply; assigning
this.state.nested.field = xis reactive - Arrays must be replaced (
this.state.items = [...items, newItem]) or mutated with methods (push,splice) - direct index assignment (this.state.items[0] = x) is NOT reactive in OWL 2
Lifecycle hooks#
OWL provides hooks for the component lifecycle:
import { onMounted, onPatched, onWillUnmount, onWillUpdateProps } from "@odoo/owl";
setup() {
onMounted(() => {
// DOM is available; safe to interact with this.el
this.chart = createChart(this.el.querySelector(".chart-container"));
});
onPatched(() => {
// Called after every re-render; DOM is up to date
this.chart.update(this.props.data);
});
onWillUnmount(() => {
// Cleanup before component is removed
this.chart.destroy();
});
onWillUpdateProps((nextProps) => {
// Runs before props change; return value ignored
if (nextProps.mode !== this.props.mode) {
this.resetState();
}
});
}Use onMounted for DOM-dependent initialisation (D3, Chart.js, CodeMirror). Always pair with onWillUnmount for cleanup to prevent memory leaks.
Injecting Odoo services#
Odoo's web client provides services (RPC, notifications, dialogs, actions) via a registry. Inject them with useService():
import { useService } from "@web/core/utils/hooks";
setup() {
this.rpc = useService("rpc");
this.notification = useService("notification");
this.action = useService("action");
this.orm = useService("orm");
}
async doSomething() {
try {
const result = await this.orm.call("my.model", "my_method", [this.props.recordId]);
this.notification.add("Done!", { type: "success" });
} catch (e) {
this.notification.add("Error: " + e.message, { type: "danger" });
}
}ORM service methods#
// search_read
const records = await this.orm.searchRead(
"sale.order",
[["state", "=", "sale"]],
["name", "amount_total"],
{ limit: 10 }
);
// write
await this.orm.write("sale.order", [orderId], { note: "Updated" });
// call arbitrary method
await this.orm.call("sale.order", "action_confirm", [orderId]);Prefer useService("orm") over raw useService("rpc") - it handles JSON-RPC framing, error normalisation, and is easier to mock in tests.
Registering a custom field widget#
To replace or add a field rendering in form/list views:
import { registry } from "@web/core/registry";
import { standardFieldProps } from "@web/views/fields/standard_field_props";
export class StarRatingField extends Component {
static template = "my_module.StarRatingField";
static props = {
...standardFieldProps,
maxStars: { type: Number, optional: true },
};
static defaultProps = { maxStars: 5 };
get value() {
return this.props.record.data[this.props.name];
}
setRating(n) {
this.props.record.update({ [this.props.name]: n });
}
}
registry.category("fields").add("star_rating", {
component: StarRatingField,
supportedTypes: ["integer", "float"],
extractProps: ({ attrs }) => ({
maxStars: parseInt(attrs.max_stars || "5"),
}),
});Then use it in a view:
<field name="satisfaction_score" widget="star_rating" max_stars="5"/>standardFieldProps provides record, name, readonly, invisible, required, and related props that all field widgets need. Always spread it into your props definition.
Registering a client action#
Client actions are full-page OWL components launched via action menu or URL:
export class MyDashboard extends Component {
static template = "my_module.MyDashboard";
}
registry.category("actions").add("my_module.my_dashboard", MyDashboard);Define the action in XML:
<record id="action_my_dashboard" model="ir.actions.client">
<field name="name">My Dashboard</field>
<field name="tag">my_module.my_dashboard</field>
</record>The tag field on ir.actions.client must match the key used in registry.category("actions").add().
Asset declaration#
All JS and XML must be declared in an asset bundle in __manifest__.py:
'assets': {
'web.assets_backend': [
'my_module/static/src/components/my_component.js',
'my_module/static/src/components/my_component.xml',
'my_module/static/src/components/my_component.scss',
],
},For CSS/SCSS scoping: OWL has no CSS isolation by default. Namespace your classes (.my-module-progress-bar) to avoid collisions with Odoo's Bootstrap-based styles.
Testing OWL components#
Use the @odoo/hoot test framework (Odoo 17+) or Jest with @web/../tests/helpers (Odoo 16):
import { mount } from "@odoo/owl";
import { MyProgressBar } from "./my_progress_bar";
QUnit.test("renders with correct percentage", async (assert) => {
const fixture = document.createElement("div");
document.body.appendChild(fixture);
await mount(MyProgressBar, fixture, {
props: { value: 50, max: 100, label: "Progress" },
});
assert.strictEqual(
fixture.querySelector(".bar").style.width,
"50%"
);
fixture.remove();
});Run Odoo JavaScript tests with:
python odoo-bin --test-tags=/my_module --stop-after-initOr run the Odoo test runner in a browser by navigating to /web/tests?module=my_module.
Common pitfalls#
Mutating props directly. Props are read-only. Never do this.props.record.data.field = x directly - use this.props.record.update(). Mutating props directly will appear to work in dev but fails unpredictably.
Missing / @odoo-module / at the top of each JS file. Odoo's bundler uses this comment to identify ES module files. Without it, your imports will fail silently or cause asset errors.
Using this.el outside of onMounted. this.el is null before the component mounts. Always access the DOM inside onMounted or later hooks.
Not cleaning up timers/subscriptions. setInterval, event listeners, and RPC abort controllers must be cleaned up in onWillUnmount. Missing cleanup causes memory leaks on view switches.
Calling hooks outside setup(). useState, onMounted, useService - all hooks must be called synchronously from setup(), never from methods or async callbacks.
For the introduction to OWL's base concepts see the OWL framework introduction. For customising views with XML inheritance see the view inheritance guide. For JavaScript RPC calls from backend views see the RPC JavaScript guide.

