The Three Required Attributes
t-foreach, t-as, and t-key always appear together on the same element. Think of them as the QWeb equivalent of JavaScript's array.map(item => <li key={item.id}>...</li>).
<ul>
<!--
t-foreach: the iterable (array or object)
t-as: name for the current item variable
t-key: unique stable identifier for each item
-->
<li
t-foreach="state.todos"
t-as="todo"
t-key="todo.id"
>
<t t-esc="todo.text"/>
</li>
</ul>
OWL will render the list without t-key, but it falls back to using the loop index as the key, which causes subtle DOM reuse bugs when items are inserted, deleted, or reordered. Always provide a stable, unique t-key from your data — typically a database ID. The loop index (todo_index) is only acceptable when the list is purely display-only and never reordered or filtered.
Built-In Loop Variables
OWL injects helper variables into every t-foreach loop. They use the t-as name as a prefix:
| Variable | Description | Example (t-as="item") |
|---|---|---|
item | Current element | item.name |
item_index | Zero-based index | item_index → 0, 1, 2… |
item_first | True for first element | item_first → true / false |
item_last | True for last element | item_last → true / false |
item_size | Total number of items | item_size → 5 |
item_value | Value when iterating an object | item_value (item = key, item_value = value) |
item_odd | True for odd-indexed items | item_odd → alternating true/false |
item_even | True for even-indexed items | item_even |
<ul>
<li
t-foreach="state.steps"
t-as="step"
t-key="step.id"
t-att-class="{
'step': true,
'step--first': step_first,
'step--last': step_last,
'step--odd': step_odd
}"
>
<span class="step-number"><t t-esc="step_index + 1"/>/<t t-esc="step_size"/></span>
<span t-esc="step.title"/>
<span t-if="step_last" class="badge">Last</span>
</li>
</ul>
Empty State Handling
Always show feedback when a list is empty. Combine t-if with t-foreach:
<!-- Pattern 1: separate t-if guards -->
<p t-if="state.items.length === 0" class="empty-state">
No items yet. <a href="#" t-on-click.prevent="addFirst">Add one</a>.
</p>
<ul t-if="state.items.length > 0">
<li t-foreach="state.items" t-as="item" t-key="item.id" t-esc="item.name"/>
</ul>
<!-- Pattern 2: using t-else -->
<ul t-if="state.items.length">
<li t-foreach="state.items" t-as="item" t-key="item.id" t-esc="item.name"/>
</ul>
<p t-else="" class="empty-state">No items.</p>
Looping Over Component Tags
You can place t-foreach directly on a component tag to render a component for each item:
<!-- Render a ProductCard for each product -->
<ProductCard
t-foreach="state.products"
t-as="product"
t-key="product.id"
name="product.name"
price="product.price"
imageUrl="product.imageUrl"
onAddToCart.bind="addToCart"
/>
Iterating Over Objects
When you pass a plain object to t-foreach, the loop variable (t-as) is the key and [as]_value is the value — equivalent to Object.entries():
<!-- state.config = { theme: 'dark', lang: 'en', timezone: 'UTC' } -->
<dl>
<t t-foreach="state.config" t-as="key" t-key="key">
<dt t-esc="key"/> <!-- key = 'theme', 'lang', 'timezone' -->
<dd t-esc="key_value"/> <!-- key_value = 'dark', 'en', 'UTC' -->
</t>
</dl>
<!-- More explicit: use Object.entries in a getter -->
class ConfigTable extends Component {
state = useState({
config: { theme: "dark", lang: "en", timezone: "UTC" },
});
// More readable than using object iteration directly
get configEntries() {
return Object.entries(this.state.config);
}
static template = xml`
<dl>
<t t-foreach="configEntries" t-as="entry" t-key="entry[0]">
<dt t-esc="entry[0]"/>
<dd t-esc="entry[1]"/>
</t>
</dl>
`;
}
Nested Loops
<!-- Render a table: rows × columns -->
<table>
<tbody>
<tr t-foreach="state.rows" t-as="row" t-key="row.id">
<td
t-foreach="row.cells"
t-as="cell"
t-key="cell.id"
t-esc="cell.value"
/>
</tr>
</tbody>
</table>
<!-- Category → items tree -->
<div t-foreach="state.categories" t-as="cat" t-key="cat.id" class="category">
<h3 t-esc="cat.name"/>
<ul>
<li t-foreach="cat.items" t-as="item" t-key="item.id" t-esc="item.name"/>
</ul>
</div>
t-key Best Practices
| Scenario | Recommended t-key | Why |
|---|---|---|
| Database records (Odoo) | t-key="record.id" | IDs are unique and stable |
| Static display list, never reordered | t-key="item_index" | Index is fine when list never changes |
| Filterable/sortable list | t-key="item.id" | Index breaks when items are rearranged |
| String array (unique strings) | t-key="tag" | String itself is the identity |
| Number range | t-key="n" | Number itself is unique |
| Generated client-side items | t-key="item.clientId" | Assign a crypto.randomUUID() at creation |
t-foreach and Why t-key Is Not Optional
t-foreach renders a list, and t-key gives each item a stable identity. OWL requires the key — and getting it right is the difference between correct, efficient updates and subtle bugs.
static template = xml`
<ul>
<li t-foreach="state.todos" t-as="todo" t-key="todo.id">
<t t-esc="todo.text"/>
</li>
</ul>`;
Why the key matters
When the list changes, OWL uses the key to match old items to new ones so it can reuse DOM nodes instead of rebuilding everything. Use a stable, unique value — a database id. Using the array index as the key is the classic bug: insert or reorder an item and the keys shift, so OWL reuses the wrong nodes — checkbox state, focus, and input values end up on the wrong rows.
| Key choice | Result |
|---|---|
item.id (stable) | ✅ correct, efficient reuse |
item_index | ⚠️ breaks on insert/reorder |
Inside the loop, t-as names the item; item_index gives its position automatically. This is the same keyed-diffing idea React and Vue use — the framework can only track items correctly if you tell it what makes each one unique.
🏋️ Practical Exercise
Build a DataGrid component with full list rendering features:
- State: array of
{ id, name, department, salary }employees (at least 8). - Render a
<table>. Uset-foreachfor rows. Apply alternating row colors usingitem_odd/item_evenviat-att-class. - Show row numbers using
item_index + 1. - Add a "Department" filter dropdown. Use a getter to return filtered employees. When filter is "All", show all rows.
- Show an empty state message when no employees match the filter.
- Add a "Delete" button per row. Use an inline arrow function to pass the employee ID to a delete method.
🔥 Challenge Exercise
Render a dynamic list from state using t-foreach with a proper t-key, including an empty-state fallback. Explain why t-key is required and how it helps OWL efficiently update the DOM on changes.
📋 Summary
t-foreach,t-as, andt-keyalways appear together. Never omitt-key.- Loop variables:
[as]_index,[as]_first,[as]_last,[as]_size,[as]_odd,[as]_even. - For object iteration: the loop variable is the key,
[as]_valueis the value. - Always show an empty state when the list might be empty — use
t-ifbefore or after the loop. t-foreachworks on both HTML elements and component tags.- Use a stable unique value from your data for
t-key— not the loop index when the list can be filtered or reordered.
Interview Questions
- How do you render a list in OWL?
- What does
t-foreachdo? - Why is
t-keyimportant? - What happens without a unique key?
- How do you access the loop index?
Related Topics
FAQ
Duplicate keys cause undefined behavior — OWL may reuse the wrong DOM node during diffing, leading to mismatched content, incorrect event handlers, or component state attached to the wrong item. Always ensure keys are unique across the entire list. If your data source has duplicate IDs (e.g., after a merge), deduplicate before rendering or generate a composite key like t-key="item.type + '-' + item.id".
Yes — <t t-foreach="items" t-as="item" t-key="item.id">...</t> renders the children for each item without wrapping them in an extra DOM element. This is useful when you need to render multiple sibling elements per item (e.g., a <dt>/<dd> pair for each entry in a definition list) without adding an extra wrapper that would break the HTML structure.
Make sure your array is inside a useState object — not a plain class property. Plain properties are not reactive. this.items.push(x) on a plain array does nothing visible. With this.state.items.push(x) (where state = useState({items: []})), the Proxy intercepts the push and schedules a re-render. If you do have useState but still see no update, check whether you are accidentally shadowing: const items = this.state.items; items.push(x); — the shadow variable is a Proxy reference, so this should work. The common real mistake is using a plain array instead of putting it in state.
Use the item_index variable (zero-based) and add 1: <span t-esc="item_index + 1"/>. For an HTML ordered list, you can also use a plain <ol> and let the browser handle numbering automatically — no JavaScript needed.

