Way4Tech

Part 2. Components · lesson 6 of 40

QWeb templates in OWL: every directive you will use

After this lesson you can: Write any OWL template, choose correctly between t-esc and t-out, and avoid the t-foreach key bug.

Written against Odoo 19.0 and 18.0. Last checked 2026-10-09.

OWL templates are QWeb, the same language Odoo uses for reports and views, with extra directives for components. If you have written an Odoo report you already know half of this.

One thing to settle first: a template has exactly one root element. Wrap siblings in a <t> if you need to return several.

Printing values

<t t-esc="partner.name"/>        <!-- escaped text, the default choice -->
<t t-out="record.body_html"/>    <!-- raw HTML, only for trusted content -->

t-esc escapes. Use it everywhere unless you have a reason not to.

t-out injects HTML without escaping. It is the correct tool for a field that genuinely holds markup, such as an email body from html field, and a security hole anywhere else. If the value can contain anything a user typed, t-esc.

Inline interpolation also works in attributes and text:

<div>Total: {{ record.amount }}</div>

Conditionals

<div t-if="state.loading">Loading</div>
<div t-elif="state.error">Something went wrong</div>
<div t-else="">Nothing to report</div>

t-else needs an empty value, t-else="". The branches must be adjacent siblings; anything between them breaks the chain.

To test without emitting an element, use <t>:

<t t-if="hasInvoices">
    <InvoiceList invoices="state.invoices"/>
</t>

Loops, and the key

<ul>
    <li t-foreach="state.invoices" t-as="invoice" t-key="invoice.id">
        <t t-esc="invoice.name"/>
    </li>
</ul>

t-key is required and it matters. OWL uses it to decide which DOM nodes to keep when the list changes. Get it wrong and you get the single most confusing class of bug in this framework: you delete the second row and the third rowโ€™s text appears in the second rowโ€™s position, or an input keeps the value typed into a row that has gone.

Use a stable, unique identifier. A database id is ideal.

Do not use the index unless the list is genuinely fixed and never reordered:

<!-- Wrong whenever the list can change -->
<li t-foreach="items" t-as="item" t-key="item_index">

Inside the loop you also get item_index, item_first, item_last and item_value, named from your t-as.

Looping an object iterates its keys:

<t t-foreach="Object.entries(totals)" t-as="entry" t-key="entry[0]">
    <t t-esc="entry[0]"/>: <t t-esc="entry[1]"/>
</t>

Attributes

<div t-att-title="invoice.name">...</div>
<div t-attf-class="badge badge-{{ invoice.state }}">...</div>
<div t-att="dynamicAttributes">...</div>

t-att-x sets one attribute from an expression. t-attf-x builds a string with {{ }} interpolation, which is what you want for classes. t-att spreads an object of attributes.

For conditional classes there is a cleaner form taking an object:

<div t-att-class="{ 'text-danger': invoice.overdue, 'fw-bold': invoice.isNew }">

Static and dynamic classes combine, so class="card" alongside t-att-class works as expected.

Events

<button t-on-click="onSave">Save</button>
<button t-on-click="() => this.remove(invoice.id)">Remove</button>
<form t-on-submit.prevent="onSubmit">...</form>
<input t-on-keydown.stop="onKey"/>

Modifiers .prevent, .stop and .capture do what their DOM equivalents do and save you writing it in the handler. Events get their own lesson next.

Two way binding

<input type="text" t-model="state.search"/>
<input type="checkbox" t-model="state.onlyOverdue"/>
<select t-model="state.period">
    <option value="month">This month</option>
</select>

t-model only works against reactive state, so the target must be something from useState. Pointing it at a plain property fails quietly: typing updates nothing and nothing re-renders.

Refs

<input t-ref="searchInput"/>
setup() {
    this.searchInput = useRef("searchInput");
    onMounted(() => this.searchInput.el?.focus());
}

useRef returns an object whose .el is the DOM node, or null when the element is not rendered. Always use ?. because a t-if can remove it at any time.

Sub templates

<t t-call="owl_course.InvoiceRow">
    <t t-set="invoice" t-value="currentInvoice"/>
</t>

t-call renders another template inline, sharing scope. It is useful for repeated markup inside a single component. For anything with its own behaviour, use a child component instead.

Local variables

<t t-set="total" t-value="state.invoices.length"/>
<p>There are <t t-esc="total"/> invoices</p>

What is different from report QWeb

Worth knowing if you came from writing Odoo reports.

t-key is required on t-foreach. Reports do not need it because they render once to a PDF and never update.

t-field does not exist. There is no record to read a field from. OWL templates work on plain JavaScript values.

Components exist. <MyComponent prop="value"/> has no equivalent in report QWeb.

Expressions are JavaScript, not Python. invoice.name or 'Draft' works because JavaScript has or through ||โ€ฆ actually it does not: write invoice.name || 'Draft'. This is a genuine and frequent slip when moving between the two.

Rendering repeats. A report template runs once. A component template runs on every state change, so anything expensive inside it costs on every render.

What is next

Props: passing data into a component, validating it, and the rule about never writing to it.

Back to the full course