Advertisement
🦉 Dynamic Templates

OWL JS List Rendering – t-foreach, t-as, and t-key

Rendering lists — arrays of items, database records, table rows — is one of the most common tasks in any OWL application. The t-foreach directive iterates over any JavaScript array or object, t-as names the loop variable, and t-key provides the stable identity OWL needs for efficient DOM diffing. This lesson covers every aspect of list rendering: the three required attributes, built-in loop variables, nested loops, iterating over objects, empty state handling, and the subtle key pitfalls that cause list-rendering bugs.

⏱️ 20 min read 🎯 Beginner 📅 Updated 2026 👁️ Lesson 2 of 5

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>).

XML – basic t-foreach
<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>
⚠️
All three attributes are required — never omit t-key

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:

VariableDescriptionExample (t-as="item")
itemCurrent elementitem.name
item_indexZero-based indexitem_index → 0, 1, 2…
item_firstTrue for first elementitem_first → true / false
item_lastTrue for last elementitem_last → true / false
item_sizeTotal number of itemsitem_size → 5
item_valueValue when iterating an objectitem_value (item = key, item_value = value)
item_oddTrue for odd-indexed itemsitem_odd → alternating true/false
item_evenTrue for even-indexed itemsitem_even
XML – using loop variables
<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>
Advertisement

Empty State Handling

Always show feedback when a list is empty. Combine t-if with t-foreach:

XML – empty state pattern
<!-- 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:

XML – loop on a component
<!-- 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():

XML – iterating an object
<!-- 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 -->
JavaScript – Object.entries via 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

XML – nested t-foreach
<!-- 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

ScenarioRecommended t-keyWhy
Database records (Odoo)t-key="record.id"IDs are unique and stable
Static display list, never reorderedt-key="item_index"Index is fine when list never changes
Filterable/sortable listt-key="item.id"Index breaks when items are rearranged
String array (unique strings)t-key="tag"String itself is the identity
Number ranget-key="n"Number itself is unique
Generated client-side itemst-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 choiceResult
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:

  1. State: array of { id, name, department, salary } employees (at least 8).
  2. Render a <table>. Use t-foreach for rows. Apply alternating row colors using item_odd / item_even via t-att-class.
  3. Show row numbers using item_index + 1.
  4. Add a "Department" filter dropdown. Use a getter to return filtered employees. When filter is "All", show all rows.
  5. Show an empty state message when no employees match the filter.
  6. 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, and t-key always appear together. Never omit t-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]_value is the value.
  • Always show an empty state when the list might be empty — use t-if before or after the loop.
  • t-foreach works 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-foreach do?
  • Why is t-key important?
  • What happens without a unique key?
  • How do you access the loop index?

FAQ

What happens if two items have the same t-key value? +

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".

Can I use t-foreach on a <t> tag? +

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.

Why does my list not update when I push to the array? +

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.

How do I render a numbered list starting from 1? +

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.