Part 2. Components · lesson 10 of 40
Splitting a screen into components without making a mess
After this lesson you can: Decide where a component should end, and extract shared behaviour into a hook instead of a base class.
Written against Odoo 19.0 and 18.0. Last checked 2026-10-09.
Nobody writes a 600 line component on purpose. It happens by adding one reasonable thing at a time. This lesson is the judgement for when to stop.
Three signals it should be two components
It renders a list of things that each have behaviour. A row that can be selected, expanded or edited has its own state. Keeping that state in the parent means an object keyed by row id, and that is a parent doing a childโs job.
A section has state nothing else reads. If isFilterPanelOpen is only ever
touched by the filter panel, the filter panel should own it. State in the parent
re-renders the parent, which re-renders everything.
You are scrolling to see the whole template. Not a rule, but a reliable symptom.
Two signals it should stay one
The pieces share state that changes constantly. Splitting them means threading props back and forth, and the split costs more than it saves.
The child would only ever have one parent and no state. That is a sub
template. Use t-call, or just leave the markup where it is. A component is a
heavier thing than a block of markup, and a component used once with no
behaviour is overhead with a nice name.
Prop drilling, and what it is telling you
<Dashboard currency="currency">
<InvoiceSection currency="currency">
<InvoiceList currency="currency">
<InvoiceRow currency="currency">
InvoiceSection and InvoiceList do not use currency. They carry it. Three
components know about a value only one of them needs, and adding a second such
value means touching all four files again.
Two proper fixes.
For values the whole screen needs, use the environment. useSubEnv adds to
the env for a component and everything below it:
// Near the top
setup() {
useSubEnv({ currency: this.props.currency });
}
// Anywhere below, at any depth
setup() {
this.currency = this.env.currency;
}
Use this sparingly. Anything in the env is an invisible dependency: a component
reading this.env.currency cannot be reused somewhere that does not provide it,
and nothing in its signature says so. Good candidates are genuinely ambient
things, the current company, a display mode, a formatting locale.
For values the whole application needs, use a service. That is Part 5, and it is the right answer more often than the env.
Reusable hooks, not base classes
The instinct from other frameworks is a base class:
// Resist this
export class LoadableComponent extends Component {
setup() {
this.state = useState({ loading: false, error: null });
}
}
It works until a component needs two behaviours, and JavaScript has no multiple inheritance. Composition does not have that limit.
A hook in OWL is just a function that calls other hooks. Nothing magical:
// addons/owl_course/static/src/hooks/use_loader.js
import { useState } from "@odoo/owl";
export function useLoader() {
const state = useState({ loading: false, error: null });
async function run(fn) {
state.loading = true;
state.error = null;
try {
return await fn();
} catch (e) {
state.error = e.message || String(e);
throw e;
} finally {
state.loading = false;
}
}
return { state, run };
}
setup() {
this.loader = useLoader();
this.orm = useService("orm");
onWillStart(() =>
this.loader.run(async () => {
this.invoices = await this.orm.searchRead("account.move", [], ["name"]);
}),
);
}
<div t-if="loader.state.loading">Loading</div>
<div t-elif="loader.state.error" class="text-danger" t-esc="loader.state.error"/>
Any component can use this one and three others. The convention is a use
prefix and a file under hooks/.
The one rule: hooks are called at the top of setup, unconditionally. Not
inside an if, not inside a callback, not inside a loop. OWL ties hooks to the
component being set up, and calling one later or conditionally breaks that
association.
// Wrong
setup() {
if (this.props.editable) {
this.state = useState({}); // sometimes called, sometimes not
}
}
// Right
setup() {
this.state = useState({ editing: false });
if (this.props.editable) {
this.state.editing = true;
}
}
One component per file
Each component in its own file, named after it, with its XML template beside it:
static/src/
invoice_panel/
invoice_panel.js
invoice_panel.xml
invoice_panel.scss
invoice_row/
invoice_row.js
invoice_row.xml
hooks/
use_loader.js
This matches how Odoo organises its own web client, which matters more than it sounds: when you are reading core code to work out how something is done, a familiar layout is half the battle.
A worked split
Before, one component doing everything:
export class InvoiceDashboard extends Component {
setup() {
this.state = useState({
invoices: [], loading: false, filter: "all",
expandedIds: [], editingId: null, draftName: "",
});
}
// twelve methods
}
After:
// InvoiceDashboard: owns data and the filter
export class InvoiceDashboard extends Component {
static components = { FilterBar, InvoiceList };
setup() {
this.loader = useLoader();
this.state = useState({ invoices: [], filter: "all" });
}
}
// FilterBar: owns nothing, reports upward
// InvoiceList: owns the loop
// InvoiceRow: owns expanded and editing, for itself
expandedIds and editingId disappear entirely. They existed only because the
parent was tracking state on behalf of children that should have had it. That is
the usual outcome of a good split: not moved code, deleted code.
What is next
Part 3, reactivity. Why a change to state redraws the screen, and the three ways people switch that off without realising.