Odoo OWL Complete Guide: Building Modern Odoo Frontends
Odoo OWL Complete Guide: Building Modern Odoo Frontends
If you’re developing for Odoo 17 or later, you need to know OWL. Period.
Odoo Web Library (OWL) is the new JavaScript framework that powers Odoo’s web client starting from version 17. It’s not just “another React clone” - it’s specifically designed for business applications, with patterns that map naturally to Odoo’s data model.
I’ve been building OWL components for about a year now, and this guide covers everything I wish I knew when I started.
Table of Contents
- Why OWL Matters
- Module Setup
- Component Basics
- The Template System
- Understanding Hooks
- Working with Odoo Services
- Props and Events
- Building Forms
- Lists and Relational Data
- Dialogs and Modals
- Widget System
- Testing
- Migration Guide
- Common Pitfalls
Why OWL Matters
Before we dive in, let’s understand why OWL was created:
The Old Way: Legacy Web Client
// This is what we had before OWL
var MyWidget = Widget.extend({
start: function() {
this._super();
this.$el.find('.btn').click(this.do_something);
},
do_something: function() {
// DOM manipulation everywhere
// No clear data flow
// Hard to test
}
});
The OWL Way
// This is OWL
import { Component, useState } from "@odoo/owl";
export class MyComponent extends Component {
static template = "my.component";
setup() {
this.state = useState({ clicked: false });
}
doSomething() {
this.state.clicked = true;
// UI updates automatically!
}
}
The difference? OWL gives you:
- Reactive data binding - changes to state automatically update the UI
- Component isolation - no more spaghetti code
- Cleaner mental model - data flows one way
- Better performance - optimized rendering
Module Setup
First, you need a properly configured module. Here’s the minimum you need:
Directory Structure
my_module/
├── __init__.py
├── __manifest__.py
├── controllers/
│ └── __init__.py
├── models/
│ └── __init__.py
└── static/
└── src/
├── js/
│ ├── component.js
│ └── component.xml
└── scss/
└── component.scss
Manifest Configuration
# __manifest__.py
{
'name': 'My OWL Module',
'description': 'My first OWL module',
'category': 'Manufacturing',
'version': '1.0',
'depends': ['web'],
'data': [
# qweb views
'views/templates.xml',
],
'assets': {
'web.assets_backend': [
'my_module/static/src/js/component.js',
'my_module/static/src/scss/component.scss',
],
},
'license': 'LGPL-3',
}
The JavaScript Entry Point
/** @odoo-module alias=my_module.components */
import { registry } from "@web/core/registry";
import { MyComponent } from "./my_component";
registry.category("webComponents").add("my.component", MyComponent);
The @odoo-module annotation is critical - it tells Odoo’s build system to process this file. The alias lets you import this module from elsewhere.
Component Basics
Let’s build a real component step by step.
The Simplest Component
// my_component.js
/** @odoo-module */
import { Component, useState } from "@odoo/owl";
export class HelloWorld extends Component {
static template = "hello.world";
setup() {
// This is the constructor - initialize state here
this.message = "Hello, World!";
}
}
<!-- my_component.xml -->
<templates>
<t t-name="hello.world">
<div class="hello-world">
<p><t t-esc="message"/></p>
</div>
</t>
</templates>
Adding Interactivity
Here’s a counter with buttons:
/** @odoo-module */
import { Component, useState } from "@odoo/owl";
export class Counter extends Component {
static template = "counter.template";
setup() {
this.state = useState({ count: 0 });
}
increment() {
this.state.count++;
}
decrement() {
this.state.count--;
}
reset() {
this.state.count = 0;
}
}
<templates>
<t t-name="counter.template">
<div class="counter d-flex align-items-center gap-2">
<button class="btn btn-secondary" t-on-click="decrement">-</button>
<span class="h3 mb-0" t-esc="state.count"/>
<button class="btn btn-secondary" t-on-click="increment">+</button>
<button class="btn btn-link" t-on-click="reset">Reset</button>
</div>
</t>
</templates>
Key things to notice:
useState()creates reactive statet-on-clickbinds click events to methodst-escdisplays escaped text- When
state.countchanges, the UI updates automatically
The Template System
OWL’s template system is powerful but has a learning curve. Let’s cover the essentials.
Template Syntax
| Syntax | Description |
|---|---|
t-esc |
Display escaped text |
t-raw |
Display raw HTML |
t-if |
Conditional rendering |
t-foreach |
Loop over arrays/objects |
t-on-click |
Event handler |
t-att |
Dynamic attributes |
t-ref |
DOM element reference |
t-slot |
Slot for composition |
Conditionals
<t t-if="state.isLoggedIn">
<span>Welcome, <t t-esc="state.userName"/>!</span>
</t>
<t t-else="">
<a href="/login">Please log in</a>
</t>
Loops
<!-- Loop over partners -->
<t t-foreach="partners" t-as="partner" t-key="partner.id">
<div class="partner-card">
<span t-esc="partner.name"/>
</div>
</t>
Dynamic Attributes
<!-- Add class conditionally -->
<button t-att-class="state.isActive ? 'btn btn-primary' : 'btn btn-secondary'">
Click me
</button>
<!-- Multiple attributes -->
<input
type="text"
t-att-value="state.inputValue"
t-att-disabled="state.isDisabled"
/>
Event Modifiers
<!-- Prevent default -->
<form t-on-submit.prevent="handleSubmit">
<!-- Stop propagation -->
<button t-on-click.stop="handleClick">
<!-- Capture mode -->
<div t-on-click.capture="handleCapture">
<!-- Once -->
<button t-on-click.once="handleOnce">
Slots (Component Composition)
This is huge for building reusable components:
// Modal.js
export class Modal extends Component {
static template = "modal.template";
static props = {
title: String,
slots: Object, // Contains named slots
};
}
<t t-name="modal.template">
<div class="modal-backdrop">
<div class="modal-content">
<header class="modal-header">
<h4 t-esc="props.title"/>
<button t-on-click="props.slots.default()">Close</button>
</header>
<div class="modal-body">
<t t-slot="default"/>
</div>
<footer class="modal-footer">
<t t-slot="footer"/>
</footer>
</div>
</div>
</templates>
// Using the modal
import { Modal } from "./modal";
export class ParentComponent extends Component {
static template = "parent.template";
static components = { Modal };
setup() {
this.state = useState({ showModal: false });
}
openModal() {
this.state.showModal = true;
}
closeModal() {
this.state.showModal = false;
}
}
<t t-name="parent.template">
<button t-on-click="openModal">Open Modal</button>
<t t-if="state.showModal">
<Modal title="Confirm Action">
<p>Are you sure?</p>
<button t-on-click="closeModal">Yes</button>
<button t-on-click="closeModal">No</button>
</Modal>
</t>
</templates>
Understanding Hooks Deeply
Hooks are the heart of OWL. Let’s cover each one in detail.
useState - Reactive State
import { Component, useState } from "@odoo/owl";
export class MyComponent extends Component {
setup() {
// Simple value
this.state = useState({ value: 0 });
// Array
this.items = useState([]);
// Nested object - still reactive!
this.form = useState({
name: '',
email: '',
address: {
street: '',
city: ''
}
});
// Updating triggers re-render
this.state.value = 5; // UI updates
this.form.address.city = 'London'; // Also reactive!
}
}
useRef - DOM Access
When you need direct DOM access:
import { Component, useState, useRef } from "@odoo/owl";
export class InputFocus extends Component {
setup() {
this.inputRef = useRef("myInput");
}
focusInput() {
this.inputRef.el.focus();
}
getInputValue() {
return this.inputRef.el.value;
}
}
<t t-name="input.focus">
<input type="text" t-ref="myInput"/>
<button t-on-click="focusInput">Focus</button>
</t>
useEffect - Side Effects
For operations that happen outside the render cycle:
import { Component, useState, useEffect } from "@odoo/owl";
export class DataLoader extends Component {
setup() {
this.state = useState({ data: null, loading: true });
// Runs after render
useEffect(() => {
this.loadData();
}, []);
// Runs when dependencies change
useEffect(() => {
console.log("Search term changed:", this.state.searchTerm);
this.search();
}, () => [this.state.searchTerm]);
}
async loadData() {
// Load something
}
}
The callback function can return a cleanup function:
useEffect(() => {
const handleResize = () => {
this.windowWidth = window.innerWidth;
};
window.addEventListener('resize', handleResize);
// Cleanup when component unmounts
return () => {
window.removeEventListener('resize', handleResize);
};
}, []);
useCallback - Memoized Functions
Prevent recreating functions on every render:
import { Component, useState, useCallback } from "@odoo/owl";
export class CallbackExample extends Component {
setup() {
this.state = useState({ count: 0 });
// Recreated on every render - may cause child re-renders
this.handleClick = () => {
console.log("Clicked");
};
// Memoized - same function reference
this.handleClickMemo = useCallback(() => {
console.log("Clicked");
}, []); // dependencies
}
}
useStore - Global State
For sharing state across components:
import { Component, useStore } from "@web/core/store/store";
export class UserInfo extends Component {
setup() {
// Access global user state
this.user = useStore((state) => state.user);
}
}
useService - Using Odoo Services
This is how you access Odoo’s internal services:
import { Component, useState } from "@odoo/owl";
import { useService } from "@web/core/utils/hooks";
export class PartnerList extends Component {
static template = "partner.list";
setup() {
this.orm = useService("orm");
this.notification = useService("notification");
this.dialog = useService("dialog");
this.action = useService("action");
}
async loadPartners() {
try {
const partners = await this.orm.searchRead(
"res.partner",
[["customer", "=", true]],
["name", "email", "phone"],
{ limit: 100 }
);
this.partners = partners;
} catch (error) {
this.notification.add("Failed to load partners", {
type: "danger"
});
}
}
openPartnerForm(partnerId) {
this.action.doAction({
type: "ir.actions.act_window",
res_model: "res.partner",
res_id: partnerId,
views: [[false, "form"]],
});
}
}
Working with Odoo Services
Odoo provides many services you can use in OWL components.
ORM Service
The main service for database operations:
this.orm = useService("orm");
// Search records
const partners = await this.orm.searchRead(
"res.partner", // Model
[["customer", "=", true], ["country_id", "=", 1]], // Domain
["name", "email", "country_id"], // Fields
{ limit: 50, order: "name" } // Options
);
// Create
const newId = await this.orm.create("res.partner", {
name: "New Partner",
customer: true
});
// Write
await this.orm.write("res.partner", [id], {
name: "Updated Name"
});
// Delete
await this.orm.unlink("res.partner", [id]);
// Call a method
const result = await this.orm.call(
"res.partner",
"compute_something",
[id],
{ arg1: "value" }
);
// Search count
const count = await this.orm.searchCount(
"res.partner",
[["customer", "=", true]]
);
Notification Service
this.notification = useService("notification");
// Success
this.notification.add("Record saved!", { type: "success" });
// Error
this.notification.add("Something went wrong", { type: "danger" });
// Warning
this.notification.add("Please check the form", { type: "warning" });
// Info
this.notification.add("Processing...", { type: "info" });
Dialog Service
this.dialog = useService("dialog");
// Open a confirmation dialog
this.dialog.add(ConfirmationDialog, {
title: "Confirm Delete",
body: "Are you sure you want to delete this record?",
confirm: () => {
// Handle confirm
},
cancel: () => {
// Handle cancel
}
});
// Open a form dialog
this.dialog.add(FormController, {
resModel: "res.partner",
resId: this.partnerId,
onSaved: () => {
this.loadData();
}
});
Action Service
this.action = useService("action");
// Open a window action
await this.action.doAction({
type: "ir.actions.act_window",
name: "Partners",
res_model: "res.partner",
views: [
[false, "list"],
[false, "form"]
],
domain: [["customer", "=", true]]
});
// Execute a server action
await this.action.doAction("my.server.action.xml_id");
// Go back
this.action.back();
Props and Events
Defining Props
Props are how parents pass data to children:
export class PartnerCard extends Component {
static props = {
partner: Object,
onSelect: Function,
};
static template = "partner.card";
select() {
this.props.onSelect(this.props.partner.id);
}
}
<t t-name="partner.card">
<div class="partner-card" t-on-click="select">
<span t-esc="props.partner.name"/>
</div>
</t>
Prop Types and Validation
export class MyComponent extends Component {
// Define expected props with types
static props = {
// Required primitive
title: String,
// Optional with default
size: {
type: String,
default: "medium"
},
// Multiple types
value: [String, Number],
// Object with shape validation
config: {
type: Object,
optional: true,
shape: {
editable: Boolean,
deletable: { type: Boolean, optional: true }
}
},
// Array of specific type
items: {
type: Array,
element: {
type: Object,
shape: {
id: Number,
name: String
}
}
},
// Callback function
onChange: Function,
// Boolean flag
isActive: {
type: Boolean,
default: true
}
};
}
Emitting Events
export class ChildComponent extends Component {
static template = "child.template";
static props = ["onData"];
sendData() {
this.props.onData({
id: 1,
name: "Test"
});
}
}
<t t-name="child.template">
<button t-on-click="sendData">Send</button>
</t>
// Parent usage
<ChildComponent onData="(data) => handleData(data)"/>
Building Forms
Forms are the most common component. Here’s how to build them properly.
Basic Form Pattern
import { Component, useState } from "@odoo/owl";
import { useService } from "@web/core/utils/hooks";
export class PartnerForm extends Component {
static template = "partner.form";
static props = {
partnerId: { type: Number, optional: true },
onSave: { type: Function, optional: true },
onCancel: { type: Function, optional: true },
};
setup() {
this.orm = useService("orm");
this.notification = useService("notification");
this.state = useState({
name: "",
email: "",
phone: "",
loading: false,
saving: false
});
if (this.props.partnerId) {
this.loadPartner();
}
}
async loadPartner() {
this.state.loading = true;
try {
const partners = await this.orm.read(
"res.partner",
[this.props.partnerId],
["name", "email", "phone"]
);
if (partners.length) {
const p = partners[0];
this.state.name = p.name;
this.state.email = p.email || "";
this.state.phone = p.phone || "";
}
} catch (error) {
this.notification.add("Failed to load partner", { type: "danger" });
} finally {
this.state.loading = false;
}
}
async save() {
if (!this.state.name) {
this.notification.add("Name is required", { type: "warning" });
return;
}
this.state.saving = true;
try {
const data = {
name: this.state.name,
email: this.state.email,
phone: this.state.phone,
};
let partnerId;
if (this.props.partnerId) {
await this.orm.write("res.partner", [this.props.partnerId], data);
partnerId = this.props.partnerId;
} else {
partnerId = await this.orm.create("res.partner", data);
}
this.notification.add("Partner saved!", { type: "success" });
if (this.props.onSave) {
this.props.onSave(partnerId);
}
} catch (error) {
this.notification.add("Failed to save", { type: "danger" });
} finally {
this.state.saving = false;
}
}
cancel() {
if (this.props.onCancel) {
this.props.onCancel();
}
}
}
Form Validation
validate() {
const errors = {};
if (!this.state.name || this.state.name.trim() === "") {
errors.name = "Name is required";
}
if (this.state.email && !this.isValidEmail(this.state.email)) {
errors.email = "Invalid email format";
}
if (this.state.phone && !this.isValidPhone(this.state.phone)) {
errors.phone = "Invalid phone format";
}
this.errors = errors;
return Object.keys(errors).length === 0;
}
isValidEmail(email) {
return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email);
}
isValidPhone(phone) {
return /^\+?[\d\s\-()]+$/.test(phone);
}
Form Template
<t t-name="partner.form">
<div class="partner-form">
<div t-if="state.loading" class="text-center">
<span class="spinner-border"></span> Loading...
</div>
<t t-else="">
<div class="form-group">
<label>Name *</label>
<input type="text"
t-model="state.name"
t-att-class="errors.name ? 'is-invalid' : ''"/>
<small t-if="errors.name" class="text-danger"
t-esc="errors.name"/>
</div>
<div class="form-group">
<label>Email</label>
<input type="email"
t-model="state.email"
t-att-class="errors.email ? 'is-invalid' : ''"/>
<small t-if="errors.email" class="text-danger"
t-esc="errors.email"/>
</div>
<div class="form-group">
<label>Phone</label>
<input type="tel" t-model="state.phone"/>
</div>
<div class="form-actions">
<button type="button"
class="btn btn-secondary"
t-on-click="cancel">
Cancel
</button>
<button type="button"
class="btn btn-primary"
t-att-disabled="state.saving"
t-on-click="save">
<span t-if="state.saving">Saving...</span>
<span t-else="">Save</span>
</button>
</div>
</t>
</div>
</t>
Lists and Relational Data
Rendering lists of data is fundamental. Here’s how to do it right.
Basic List
export class PartnerList extends Component {
static template = "partner.list";
setup() {
this.orm = useService("orm");
this.state = useState({
partners: [],
loading: true
});
this.loadPartners();
}
async loadPartners() {
this.state.loading = true;
try {
this.state.partners = await this.orm.searchRead(
"res.partner",
[["customer", "=", true]],
["id", "name", "email", "phone"],
{ limit: 100, order: "name" }
);
} finally {
this.state.loading = false;
}
}
}
<t t-name="partner.list">
<div class="partner-list">
<table class="table table-hover">
<thead>
<tr>
<th>Name</th>
<th>Email</th>
<th>Phone</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
<tr t-foreach="state.partners" t-as="partner" t-key="partner.id">
<td t-esc="partner.name"/>
<td t-esc="partner.email"/>
<td t-esc="partner.phone"/>
<td>
<button class="btn btn-sm btn-primary"
t-on-click="() => editPartner(partner.id)">
Edit
</button>
</td>
</tr>
</tbody>
</table>
<div t-if="state.partners.length === 0" class="text-muted">
No partners found.
</div>
</div>
</t>
List with Pagination
export class PaginatedList extends Component {
static template = "paginated.list";
setup() {
this.orm = useService("orm");
this.notification = useService("notification");
this.state = useState({
partners: [],
total: 0,
page: 1,
limit: 20,
loading: false
});
this.loadPartners();
}
async loadPartners() {
this.state.loading = true;
try {
const offset = (this.state.page - 1) * this.state.limit;
this.state.partners = await this.orm.searchRead(
"res.partner",
[["customer", "=", true]],
["name", "email"],
{
limit: this.state.limit,
offset: offset,
order: "name"
}
);
this.state.total = await this.orm.searchCount(
"res.partner",
[["customer", "=", true]]
);
} finally {
this.state.loading = false;
}
}
get totalPages() {
return Math.ceil(this.state.total / this.state.limit);
}
nextPage() {
if (this.state.page < this.totalPages) {
this.state.page++;
this.loadPartners();
}
}
prevPage() {
if (this.state.page > 1) {
this.state.page--;
this.loadPartners();
}
}
}
Dialogs and Modals
Odoo has built-in support for dialogs.
Using the Dialog Service
import { useService } from "@web/core/utils/hooks";
import { ConfirmationDialog } from "@web/core/confirmation_dialog/confirmation_dialog";
export class MyComponent extends Component {
static template = "my.component";
setup() {
this.dialog = useService("dialog");
}
confirmDelete(recordId) {
this.dialog.add(ConfirmationDialog, {
title: "Confirm Deletion",
body: "Are you sure you want to delete this record? This action cannot be undone.",
confirm: async () => {
await this.deleteRecord(recordId);
},
});
}
async deleteRecord(id) {
// Delete logic
}
}
Creating Custom Dialogs
// MyDialog.js
export class MyDialog extends Component {
static template = "my.dialog";
static props = {
title: String,
initialValue: String,
onConfirm: Function,
onCancel: Function,
};
setup() {
this.state = useState({ value: this.props.initialValue || "" });
}
confirm() {
this.props.onConfirm(this.state.value);
}
}
// Usage
this.dialog.add(MyDialog, {
title: "Enter Name",
initialValue: this.currentName,
onConfirm: (value) => {
this.currentName = value;
}
});
Widget System
OWL integrates with Odoo’s widget system.
Using Widgets in OWL
<t t-name="widget.example">
<!-- Field with widget -->
<div class="o_field_widget" name="phone">
<span t-esc="record.phone.value"
t-attf-class="o_phone #{record.phone_incoming ? 'ringing' : ''}"/>
</div>
<!-- Many2one -->
<div class="o_field_many2one">
<span t-esc="record.partner_id[1]"/>
</div>
<!-- Binary (image) -->
<img t-if="record.image_128.value"
t-att-src="record.image_128.value"/>
</t>
Creating Custom Widgets
/** @odoo-module */
import { registry } from "@web/core/registry";
export class RatingWidget extends Component {
static template = "rating.widget";
static props = {
value: Number,
maxRating: { type: Number, default: 5 },
onChange: { type: Function, optional: true }
};
setup() {
this.state = useState({
hoverRating: 0
});
}
setRating(rating) {
if (this.props.onChange) {
this.props.onChange(rating);
}
}
}
registry.category("webComponents").add("rating.widget", RatingWidget);
Testing
Testing OWL components is crucial for maintainability.
Basic Test
// my_module/tests/my_component_tests.js
import { expect, describe, it } from "@odoo/hoot";
import { mount } from "@web/core/utils/render_service_mocks";
import { MyComponent } from "../my_component";
describe("MyComponent", () => {
it("renders correctly", async () => {
const component = await mount(MyComponent, {
props: {}
});
expect(component.el.textContent).toContain("Hello");
});
it("responds to click", async () => {
const component = await mount(MyComponent, {
props: {}
});
expect(component.state.count).toBe(0);
// Click the increment button
component.el.querySelector(".increment").click();
expect(component.state.count).toBe(1);
});
});
Testing RPC Calls
import { patch } from "@web/core/utils/render_service_mocks";
import { ormService } from "@web/core/orm_service";
describe("PartnerForm", () => {
it("creates a partner", async () => {
// Mock the ORM service
patch(ormService, {
create: (model, data) => {
expect(model).toBe("res.partner");
expect(data.name).toBe("Test Partner");
return Promise.resolve(1); // Return new ID
}
});
const component = await mount(PartnerForm, {
props: {}
});
component.state.name = "Test Partner";
await component.save();
// Verify notification was called
});
});
Migration Guide
If you’re migrating from legacy web client to OWL:
Key Differences
| Legacy | OWL |
|---|---|
Widget.extend() |
class extends Component |
this.$el |
useRef or direct DOM |
this.do_action() |
useService("action") |
this.orm |
useService("orm") |
def setup() |
setup() { } |
start() |
No needed - setup is async |
Common Migration Patterns
// OLD: Widget
var MyWidget = Widget.extend({
start: function() {
this.$el.find('.btn').on('click', this.onClick.bind(this));
},
onClick: function() {
this.do_action({
type: 'ir.actions.act_window',
res_model: 'res.partner',
});
}
});
// NEW: OWL Component
export class MyComponent extends Component {
static template = "my.component";
static components = {};
setup() {
this.action = useService("action");
}
async onClick() {
await this.action.doAction({
type: 'ir.actions.act_window',
res_model: 'res.partner',
});
}
}
Common Pitfalls
A few things that trip people up:
1. Forgetting useState
// ❌ WRONG - won't trigger re-render
this.count = 0;
// ✅ CORRECT
this.state = useState({ count: 0 });
this.state.count = 0;
2. Not Using the Right Service
// ❌ WRONG - using fetch or axios
fetch('/api/partner').then(...)
// ✅ CORRECT - use Odoo's ORM
this.orm = useService("orm");
this.orm.searchRead("res.partner", ...);
3. Mutating Arrays Incorrectly
// ❌ WRONG - won't trigger re-render
this.state.items.push(newItem);
// ✅ CORRECT - replace the array
this.state.items = [...this.state.items, newItem];
// Or use reactive array methods in OWL 2.0
this.state.items.push(newItem); // Works in OWL 2.0!
4. Not Handling Loading States
setup() {
this.state = useState({ loading: true });
useEffect(() => {
this.loadData();
}, []);
}
Conclusion
OWL is a significant improvement over Odoo’s legacy web client. The learning curve is worth it:
- Cleaner, more maintainable code
- Better performance out of the box
- Easier to test
- More enjoyable development experience
Start with simple components, then gradually add complexity. The patterns in this guide will take you from beginner to confident OWL developer.
The best way to learn is by building. Start with a simple form, then add lists, then dialogs. You’ll get comfortable with OWL before you know it.
What’s your experience with OWL been like? Let me know in the comments!