What Are Client Actions?
Client actions are custom JavaScript views that replace Odoo's standard form/list/kanban views with entirely custom interfaces. They are rendered client-side using Odoo's OWL framework and can do anything — dashboards, configuration wizards, data visualizations, or specialized workflows that do not fit standard view types.
When to Use Client Actions
- Custom dashboards with charts, KPIs, and interactive elements
- Setup wizards with multi-step flows
- Specialized interfaces (calendar, Gantt alternatives)
- Integration UIs (payment terminal, barcode scanner)
- Configuration panels that combine multiple settings
Basic Structure
1. 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.dashboard</field>
<field name="target">main</field>
</record>
<!-- Add to menu -->
<menuitem id="menu_my_dashboard"
name="Dashboard"
action="action_my_dashboard"
parent="menu_root"/>The tag field links the XML action to a JavaScript component registered in the action registry.
2. Create the OWL Component
/** @odoo-module **/
import { Component, useState, onWillStart } from "@odoo/owl";
import { registry } from "@web/core/registry";
import { useService } from "@web/core/utils/hooks";
class MyDashboard extends Component {
static template = "my_module.Dashboard";
setup() {
this.orm = useService("orm");
this.state = useState({
totalOrders: 0,
totalRevenue: 0,
recentOrders: [],
loading: true,
});
onWillStart(async () => {
await this.loadData();
});
}
async loadData() {
this.state.loading = true;
const result = await this.orm.call(
"sale.order",
"get_dashboard_data",
[],
);
this.state.totalOrders = result.total_orders;
this.state.totalRevenue = result.total_revenue;
this.state.recentOrders = result.recent_orders;
this.state.loading = false;
}
async onRefresh() {
await this.loadData();
}
}
registry.category("actions").add("my_module.dashboard", MyDashboard);3. Create the OWL Template
<?xml version="1.0" encoding="UTF-8"?>
<templates xml:space="preserve">
<t t-name="my_module.Dashboard">
<div class="o_action">
<div class="o_dashboard_header d-flex justify-content-between p-3">
<h2>Sales Dashboard</h2>
<button class="btn btn-primary" t-on-click="onRefresh">
Refresh
</button>
</div>
<div class="o_dashboard_content p-3" t-if="!state.loading">
<div class="row">
<div class="col-md-6">
<div class="card">
<div class="card-body">
<h5>Total Orders</h5>
<span class="h2" t-esc="state.totalOrders"/>
</div>
</div>
</div>
<div class="col-md-6">
<div class="card">
<div class="card-body">
<h5>Revenue</h5>
<span class="h2" t-esc="state.totalRevenue"/>
</div>
</div>
</div>
</div>
</div>
<div t-else="" class="text-center p-5">
<i class="fa fa-spinner fa-spin fa-3x"/>
</div>
</div>
</t>
</templates>Server Communication
Using orm Service
// Call a model method
const result = await this.orm.call(
"sale.order", // model
"get_dashboard_data", // method
[], // args
{}, // kwargs
);
// Search and read
const orders = await this.orm.searchRead(
"sale.order",
[["state", "=", "sale"]], // domain
["name", "amount_total"], // fields
{ limit: 10, order: "create_date desc" },
);
// Read group
const groups = await this.orm.readGroup(
"sale.order",
[["state", "=", "sale"]],
["amount_total:sum"],
["partner_id"],
);Using rpc Service
// For custom controller endpoints
this.rpc = useService("rpc");
const data = await this.rpc("/my_module/api/dashboard", {
date_from: "2026-01-01",
date_to: "2026-03-24",
});Available Services
| Service | Purpose |
|---|---|
| orm | ORM operations (search, read, write, call) |
| rpc | Custom HTTP endpoint calls |
| action | Navigate to other actions |
| notification | Show toast notifications |
| dialog | Open dialog windows |
| user | Current user info |
| company | Current company info |
Navigation
// Navigate to another action
this.actionService = useService("action");
this.actionService.doAction({
type: "ir.actions.act_window",
res_model: "sale.order",
res_id: orderId,
views: [[false, "form"]],
});
// Open in new tab
this.actionService.doAction({
type: "ir.actions.act_url",
url: "/web#id=" + orderId + "&model=sale.order&view_type=form",
target: "new",
});Asset Registration
<!-- In __manifest__.py -->
'assets': {
'web.assets_backend': [
'my_module/static/src/js/dashboard.js',
'my_module/static/src/xml/dashboard.xml',
'my_module/static/src/css/dashboard.css',
],
},Common Pitfalls
- Tag mismatch — The
tagin ir.actions.client must exactly match the string inregistry.category("actions").add(). - Missing asset bundle — JS/XML/CSS files must be listed in __manifest__.py assets. Otherwise the component is not loaded.
- Forgetting onWillStart — Async data loading should happen in
onWillStart, not insetup(). OWL renders the component only after onWillStart resolves. - State reactivity — Use
useState()for reactive state. Direct property assignment does not trigger re-renders.