Part 4. The lifecycle · lesson 17 of 40
onMounted, refs, and working with the real DOM
After this lesson you can: Safely touch the DOM from a component, and integrate a third party library that needs a real element.
Written against Odoo 19.0 and 18.0. Last checked 2026-10-09.
onMounted fires once, after the componentโs DOM is in the document. It is the
first moment a ref has an element.
Refs
<t t-name="owl_course.SearchBox">
<input type="text" t-ref="input" t-model="state.query"/>
</t>
import { Component, useState, useRef, onMounted } from "@odoo/owl";
export class SearchBox extends Component {
static template = "owl_course.SearchBox";
setup() {
this.state = useState({ query: "" });
this.inputRef = useRef("input");
onMounted(() => this.inputRef.el?.focus());
}
}
useRef returns an object with an el property. It is null whenever the
element is not currently rendered, so always use ?.. A t-if above the
element, or a render where it is absent, and .el is null without warning.
The ref object itself is stable. The el inside it is not, and you should never
cache it:
// Wrong
setup() {
this.inputRef = useRef("input");
onMounted(() => { this.el = this.inputRef.el; });
}
// Later, after a patch, this.el may be a detached node
Read this.inputRef.el each time.
Measuring
onMounted(() => {
const rect = this.containerRef.el.getBoundingClientRect();
this.state.columns = Math.max(1, Math.floor(rect.width / 240));
});
Measuring in onMounted is correct. Measuring in setup or onWillStart is
not, because there is nothing to measure.
If you need to react to later size changes, use a ResizeObserver and clean it
up:
setup() {
this.containerRef = useRef("container");
this.state = useState({ width: 0 });
onMounted(() => {
this.observer = new ResizeObserver((entries) => {
this.state.width = entries[0].contentRect.width;
});
this.observer.observe(this.containerRef.el);
});
onWillUnmount(() => this.observer?.disconnect());
}
Odoo also ships useService("ui") for sizes and breakpoints, which is usually
better than measuring yourself. Part 5.
Third party libraries
The common real job: a chart, a map, an editor, anything that wants a DOM node.
import { Component, useRef, onMounted, onWillUnmount, useEffect } from "@odoo/owl";
import { loadJS } from "@web/core/assets";
export class RevenueChart extends Component {
static template = "owl_course.RevenueChart";
static props = { data: Object };
setup() {
this.canvasRef = useRef("canvas");
onWillStart(() => loadJS("/web/static/lib/Chart/Chart.js"));
onMounted(() => this.renderChart());
useEffect(
() => { this.chart && this.updateChart(); },
() => [this.props.data],
);
onWillUnmount(() => this.chart?.destroy());
}
renderChart() {
this.chart = new Chart(this.canvasRef.el, {
type: "bar",
data: this.props.data,
});
}
updateChart() {
this.chart.data = this.props.data;
this.chart.update();
}
}
Three things make this correct.
loadJS in onWillStart loads the library before first render, and Odoo caches
it so a second component does not fetch it twice.
onWillUnmount destroys the chart. Chart libraries attach window listeners and
hold canvas contexts. Without this you leak one chart per open, and in a backend
session that is a lot.
The chart is updated through its own API rather than by re-rendering. OWL does not know what is inside that canvas, and letting it patch around a libraryโs DOM is how you get two libraries fighting over the same nodes.
Whenever a library owns a subtree, give it a node and keep OWL out of it. Do not
put t-if or t-foreach inside the element you handed over.
onWillPatch and onPatched
A patch is OWL updating existing DOM rather than creating it. The pair exists so you can capture something before and restore it after.
The real use case is scroll position:
setup() {
this.listRef = useRef("list");
onWillPatch(() => {
const el = this.listRef.el;
this.scrollState = el
? { top: el.scrollTop, atBottom: el.scrollHeight - el.scrollTop === el.clientHeight }
: null;
});
onPatched(() => {
const el = this.listRef.el;
if (!el || !this.scrollState) return;
el.scrollTop = this.scrollState.atBottom ? el.scrollHeight : this.scrollState.top;
});
}
That is a message list that stays pinned to the bottom when new messages arrive, but does not yank the user back down if they have scrolled up to read. Odooโs own chatter does exactly this.
onPatched runs after every patch, which is every re-render after the first.
Keep it cheap, and never write reactive state in it without a guard, or you have
the loop from the previous part.
onMounted versus useEffect
onMounted(() => this.init()); // once
useEffect(() => this.sync(), () => [this.props.id]); // when id changes
onMounted for one time setup. useEffect for anything that must follow a
value. An effect with () => [] as its dependencies is effectively onMounted,
and using onMounted is clearer when that is what you mean.
What is next
onWillUpdateProps: reacting when the parent hands you a different record.