Skip to main content

Documentation Index

Fetch the complete documentation index at: https://mintlify.com/odoo/documentation/llms.txt

Use this file to discover all available pages before exploring further.

The Odoo web framework provides everything you need to build rich, reactive user interfaces inside Odoo. At its core is OWL (Odoo Web Library), an in-house component system inspired by React and Vue that powers the entire Odoo client since version 15. This tutorial teaches you how OWL components work, how to consume Odoo services from JavaScript, how the registry system connects your code to the broader application, and finally how to build a complete custom view from scratch.

Prerequisites

Before starting, ensure you have:
  • A working Odoo development environment (version 15 or later).
  • Basic knowledge of JavaScript (ES2020+), HTML, and Odoo model/controller concepts.
  • The awesome_owl or awesome_dashboard tutorial addon from the odoo/tutorials repository installed.
This tutorial focuses on the current OWL-based framework. The legacy JavaScript framework (Widget, web.AbstractAction, etc.) is deprecated and should not be used for new development.

Tutorial Progression

1

Understand OWL Components

Learn the Component class, templates, state, and props — the four pillars of every OWL component.
2

Use Odoo Services

Access built-in services (ORM, notification, dialog) with the useService hook.
3

Work with Registries

Register custom views, fields, and client actions so Odoo discovers them automatically.
4

Build a Custom View

Combine a controller, renderer, and model to create a fully functional alternative view type.

OWL Component Model

Every piece of Odoo’s UI is an OWL component. A component is a JavaScript class that pairs logic with a QWeb XML template.

The Component Class

/** @odoo-module **/

import { Component, useState } from "@odoo/owl";

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

    setup() {
        // Reactive state — any read of this object inside the template
        // will subscribe the component to re-render on change.
        this.state = useState({ value: 0 });
    }

    increment() {
        this.state.value++;
    }
}
The matching QWeb template lives in an XML file:
<templates xml:space="preserve">
    <t t-name="my_module.Counter">
        <div class="counter">
            <p>Counter: <t t-esc="state.value"/></p>
            <button class="btn btn-primary" t-on-click="increment">
                Increment
            </button>
        </div>
    </t>
</templates>

Key OWL APIs

useState wraps a plain object in a reactive proxy. When a value in the object changes, every component that read that value will automatically re-render.
import { useState } from "@odoo/owl";

setup() {
    this.state = useState({
        isOpen: false,
        items: [],
    });
}

Services System

Odoo services are singletons registered in the service registry. Any component can access them with the useService hook inside setup().

The useService Hook

/** @odoo-module **/

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

export class PropertyList extends Component {
    static template = "estate.PropertyList";

    setup() {
        // Grab services by name
        this.orm = useService("orm");
        this.notification = useService("notification");
        this.action = useService("action");

        this.state = useState({ properties: [] });

        onWillStart(async () => {
            this.state.properties = await this.orm.searchRead(
                "estate.property",
                [["state", "=", "new"]],
                ["name", "expected_price", "postcode"],
                { limit: 40 }
            );
        });
    }

    async markAsSold(propertyId) {
        try {
            await this.orm.call("estate.property", "action_sold", [[propertyId]]);
            this.notification.add("Property marked as sold!", { type: "success" });
        } catch (error) {
            this.notification.add(error.message, { type: "danger" });
        }
    }
}

Commonly Used Services

ServiceDescription
ormCall search, read, create, write, unlink, call against Odoo models.
notificationDisplay toast notifications (add(message, { type })).
actionTrigger actions programmatically (doAction, switchView).
dialogOpen modal dialogs.
routerRead and push URL state.
userAccess current user info (userId, lang, context).
rpcMake raw JSON-RPC calls to controllers.

The ORM Service in Detail

/** @odoo-module **/

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

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

    setup() {
        this.orm = useService("orm");
        onWillStart(() => this.loadRecords());
    }

    async loadRecords() {
        // search — returns list of ids
        const ids = await this.orm.search("res.partner", [["is_company", "=", true]]);

        // searchRead — returns records with specified fields
        const partners = await this.orm.searchRead(
            "res.partner",
            [["customer_rank", ">", 0]],
            ["name", "email", "phone"],
            { limit: 10, order: "name asc" }
        );

        // create — returns new record id
        const newId = await this.orm.create("estate.property", [{
            name: "New Property",
            expected_price: 250000,
        }]);

        // write — update existing records
        await this.orm.write("estate.property", [newId], { state: "offer_received" });

        // call — call a Python method
        const result = await this.orm.call(
            "estate.property",
            "action_sold",
            [[newId]],
            {}
        );
    }
}

Registries

The Odoo registry system is how JavaScript code announces its existence to the rest of the application. Rather than importing components directly, most things — views, fields, client actions, services — register themselves under a named key. The framework then looks them up by that key at runtime.

How Registries Work

/** @odoo-module **/

import { registry } from "@web/core/registry";

// Register a custom service
const myService = {
    dependencies: ["orm"],
    start(env, { orm }) {
        return {
            async fetchProperties() {
                return orm.searchRead("estate.property", [], ["name", "state"]);
            },
        };
    },
};

registry.category("services").add("estate.properties", myService);

Views Registry

The views registry maps a view type string to its implementation:
/** @odoo-module **/

import { registry } from "@web/core/registry";
import { MapController } from "./map_controller";
import { MapModel } from "./map_model";
import { MapRenderer } from "./map_renderer";

registry.category("views").add("map", {
    type: "map",
    display_name: "Map",
    icon: "oi-view-map",
    searchMenuTypes: ["filter", "groupBy"],
    Controller: MapController,
    Renderer: MapRenderer,
    Model: MapModel,
});

Fields Registry

Override or extend how a field type is displayed in forms and lists:
/** @odoo-module **/

import { registry } from "@web/core/registry";
import { PriceField } from "./price_field";

// Register a custom field widget under the "price_display" widget name
registry.category("fields").add("price_display", PriceField);

Building a Custom View

A custom view in Odoo consists of three cooperating classes: a Controller, a Renderer, and a Model. This mirrors the MVC pattern.

Controller

Manages the view lifecycle, loads the model, and passes data down to the renderer. Extends Component.

Renderer

Renders the actual UI. Receives props from the controller and emits events upward. Extends Component.

Model

Fetches and holds data from the server. A plain JavaScript class (not a Component).
gallery_model.js — Fetches records:
/** @odoo-module **/

export class GalleryModel {
    constructor(env, resModel, fields, domain) {
        this.env = env;
        this.resModel = resModel;
        this.fields = fields;
        this.domain = domain;
        this.records = [];
    }

    async load() {
        this.records = await this.env.services.orm.searchRead(
            this.resModel,
            this.domain,
            this.fields,
            { limit: 80 }
        );
    }
}
gallery_renderer.js — Renders the grid:
/** @odoo-module **/

import { Component } from "@odoo/owl";

export class GalleryRenderer extends Component {
    static template = "estate.GalleryRenderer";
    static props = {
        records: Array,
        onRecordClick: Function,
    };
}
gallery_renderer.xml — Template:
<templates xml:space="preserve">
    <t t-name="estate.GalleryRenderer">
        <div class="o_gallery_view">
            <t t-foreach="props.records" t-as="record" t-key="record.id">
                <div class="o_gallery_card" t-on-click="() => props.onRecordClick(record)">
                    <span class="fw-bold" t-esc="record.name"/>
                    <span class="text-muted" t-esc="record.postcode"/>
                </div>
            </t>
        </div>
    </t>
</templates>
gallery_controller.js — Orchestrates model + renderer:
/** @odoo-module **/

import { Component, onWillStart, useState } from "@odoo/owl";
import { useService } from "@web/core/utils/hooks";
import { GalleryModel } from "./gallery_model";
import { GalleryRenderer } from "./gallery_renderer";

export class GalleryController extends Component {
    static template = "estate.GalleryController";
    static components = { GalleryRenderer };

    setup() {
        this.action = useService("action");
        this.orm = useService("orm");

        this.model = new GalleryModel(
            this.env,
            this.props.resModel,
            ["name", "postcode", "expected_price"],
            this.props.domain ?? []
        );

        this.state = useState({ records: [] });

        onWillStart(async () => {
            await this.model.load();
            this.state.records = this.model.records;
        });
    }

    openRecord(record) {
        this.action.switchView("form", { resId: record.id });
    }
}
gallery_controller.xml — Controller template:
<templates xml:space="preserve">
    <t t-name="estate.GalleryController">
        <div class="o_gallery_controller">
            <GalleryRenderer
                records="state.records"
                onRecordClick.bind="openRecord"
            />
        </div>
    </t>
</templates>
Register the view:
/** @odoo-module **/

import { registry } from "@web/core/registry";
import { GalleryController } from "./gallery_controller";
import { GalleryRenderer } from "./gallery_renderer";

registry.category("views").add("gallery", {
    type: "gallery",
    display_name: "Gallery",
    icon: "oi-view-list",
    searchMenuTypes: ["filter", "groupBy", "favorite"],
    Controller: GalleryController,
    Renderer: GalleryRenderer,
    Model: null, // model is instantiated directly in the controller
});
Include the JS assets in __manifest__.py:
'assets': {
    'web.assets_backend': [
        'estate/static/src/views/gallery_model.js',
        'estate/static/src/views/gallery_renderer.js',
        'estate/static/src/views/gallery_renderer.xml',
        'estate/static/src/views/gallery_controller.js',
        'estate/static/src/views/gallery_controller.xml',
        'estate/static/src/views/gallery_view.js',
    ],
},
Reference the view type in an action:
<record id="action_estate_property" model="ir.actions.act_window">
    <field name="name">Properties</field>
    <field name="res_model">estate.property</field>
    <field name="view_mode">list,form,gallery</field>
</record>

Build docs developers (and LLMs) love