Ad – 728Γ—90
πŸ¦‰ Getting Started

Your First OWL JS App – Build a Todo List Step by Step

The best way to understand a framework is to build something real with it. In this lesson you will build a working Todo list application with OWL JS β€” step by step, from a blank file to a functional app. Along the way you will see OWL's core features in action: reactive state, template directives, event handling, list rendering, and computed getters. By the end, you will have built your first real OWL component and have a solid mental model of how OWL applications work.

⏱️ 20 min read 🎯 Beginner πŸ“… Updated 2026

What We Will Build

A Todo list app with the following features:

  • Type a task in an input and press Enter (or click Add) to add it to the list
  • Click a task to toggle it between done and not-done
  • Delete a task with a βœ• button
  • A counter showing how many tasks are remaining
  • Filter buttons: All, Active, Completed

This covers: useState, t-foreach, t-if/t-else, t-on-click, t-model, t-att-class, and JavaScript getters inside a component.

πŸ’‘
Set up first

This lesson uses the single HTML + CDN setup (Approach 1 from the Setup lesson). Create index.html and app.js in a new folder. If you have not done that yet, see Setting Up OWL JS first.

Step 1 – The HTML Shell

Create index.html. This is just the OWL bootstrap β€” all the UI comes from the component:

HTML – index.html
<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title>OWL JS Todo App</title>
  <script src="https://cdn.jsdelivr.net/npm/@odoo/owl/dist/owl.iife.js"></script>
  <style>
    body { font-family: sans-serif; max-width: 500px; margin: 3rem auto; padding: 0 1rem; }
    .todo-item { display: flex; align-items: center; gap: 0.75rem; padding: 0.6rem 0; border-bottom: 1px solid #eee; }
    .todo-item.done span { text-decoration: line-through; color: #999; }
    .todo-text { flex: 1; cursor: pointer; }
    .delete-btn { background: none; border: none; cursor: pointer; color: #e74c3c; font-size: 1.1rem; }
    .filters { display: flex; gap: 0.5rem; margin: 1rem 0; }
    .filters button { padding: 0.3rem 0.75rem; border: 1px solid #ccc; border-radius: 4px; cursor: pointer; background: white; }
    .filters button.active-filter { background: #3498db; color: white; border-color: #3498db; }
    input[type="text"] { width: 100%; padding: 0.6rem; font-size: 1rem; border: 1px solid #ccc; border-radius: 4px; box-sizing: border-box; margin-bottom: 0.5rem; }
  </style>
</head>
<body>
  <div id="app"></div>
  <script src="app.js"></script>
</body>
</html>

Step 2 – Basic Component with State

Create app.js. Start with the component class and reactive state:

JavaScript – app.js (Step 2)
const { Component, mount, xml, useState } = owl;

class TodoApp extends Component {
  // Reactive state β€” OWL re-renders when any property changes
  state = useState({
    todos: [
      { id: 1, text: "Learn OWL JS", done: false },
      { id: 2, text: "Build a real Odoo module", done: false },
    ],
    newTodo: "",      // bound to the input via t-model
    filter: "all",   // "all" | "active" | "completed"
    nextId: 3,       // auto-increment ID for new todos
  });

  static template = xml`
    <div>
      <h1>πŸ¦‰ OWL Todo App</h1>
      <p>(more coming in steps below)</p>
    </div>
  `;
}

mount(TodoApp, document.getElementById("app"));

Save and open in the browser β€” you should see the heading. Now build up the template step by step.

Step 3 – Input and Add Button

Replace the template with one that has the input field and add button. The key piece here is t-model for two-way binding and t-on-keydown to add a todo on Enter:

JavaScript – template + addTodo method
// Add these methods INSIDE the class, before static template:

addTodo() {
  const text = this.state.newTodo.trim();
  if (!text) return;
  this.state.todos.push({
    id: this.state.nextId++,
    text,
    done: false,
  });
  this.state.newTodo = "";  // clear the input
}

onKeyDown(ev) {
  if (ev.key === "Enter") this.addTodo();
}

// Replace the static template with:
static template = xml`
  <div>
    <h1>πŸ¦‰ OWL Todo App</h1>

    <!-- Two-way binding: state.newTodo mirrors the input value -->
    <input
      type="text"
      t-model="state.newTodo"
      t-on-keydown="onKeyDown"
      placeholder="What needs to be done?"
    />
    <button t-on-click="addTodo">Add Task</button>
  </div>
`;
ℹ️
t-model and two-way binding

t-model="state.newTodo" does two things: (1) sets the input's value to state.newTodo, and (2) attaches an input event listener that updates state.newTodo whenever the user types. This is identical to Vue's v-model and replaces the React pattern of value + onChange.

Ad – 336Γ—280

Step 4 – Render the Todo List

Add the todo list with t-foreach. Add a computed getter filteredTodos that returns the filtered list based on state.filter:

JavaScript – getter + delete + toggle methods
// Getter β€” computed property based on reactive state
// OWL re-evaluates this when state.todos or state.filter changes
get filteredTodos() {
  const { todos, filter } = this.state;
  if (filter === "active")    return todos.filter(t => !t.done);
  if (filter === "completed") return todos.filter(t => t.done);
  return todos;
}

get remaining() {
  return this.state.todos.filter(t => !t.done).length;
}

toggleTodo(id) {
  const todo = this.state.todos.find(t => t.id === id);
  if (todo) todo.done = !todo.done;  // direct mutation β€” OWL detects it
}

deleteTodo(id) {
  const idx = this.state.todos.findIndex(t => t.id === id);
  if (idx !== -1) this.state.todos.splice(idx, 1);
}

Step 5 – The Complete Template

Now replace the template with the full version including the list, filters, and remaining count:

JavaScript – complete template
static template = xml`
  <div>
    <h1>πŸ¦‰ OWL Todo App</h1>

    <!-- Input + Add button -->
    <input
      type="text"
      t-model="state.newTodo"
      t-on-keydown="onKeyDown"
      placeholder="What needs to be done?"
    />
    <button t-on-click="addTodo" style="margin-bottom:1rem;">Add</button>

    <!-- Filter buttons -->
    <div class="filters">
      <button
        t-att-class="{ 'active-filter': state.filter === 'all' }"
        t-on-click="() => state.filter = 'all'"
      >All</button>
      <button
        t-att-class="{ 'active-filter': state.filter === 'active' }"
        t-on-click="() => state.filter = 'active'"
      >Active</button>
      <button
        t-att-class="{ 'active-filter': state.filter === 'completed' }"
        t-on-click="() => state.filter = 'completed'"
      >Completed</button>
    </div>

    <!-- Todo list -->
    <ul style="list-style:none;padding:0;">
      <li t-foreach="filteredTodos" t-as="todo" t-key="todo.id"
          t-att-class="{ 'todo-item': true, done: todo.done }">

        <!-- Clicking the text toggles done state -->
        <span class="todo-text" t-on-click="() => toggleTodo(todo.id)">
          <t t-esc="todo.text"/>
        </span>

        <!-- Delete button -->
        <button class="delete-btn" t-on-click="() => deleteTodo(todo.id)">βœ•</button>
      </li>
    </ul>

    <!-- Empty state -->
    <p t-if="filteredTodos.length === 0" style="color:#999;text-align:center;">
      No tasks here!
    </p>

    <!-- Remaining count -->
    <p style="color:#666;font-size:0.9rem;">
      <t t-esc="remaining"/> task(s) remaining
    </p>
  </div>
`;

Complete app.js

Here is the full app.js file combining all steps:

JavaScript – complete app.js
const { Component, mount, xml, useState } = owl;

class TodoApp extends Component {
  state = useState({
    todos: [
      { id: 1, text: "Learn OWL JS", done: false },
      { id: 2, text: "Build a real Odoo module", done: false },
    ],
    newTodo: "",
    filter: "all",
    nextId: 3,
  });

  get filteredTodos() {
    const { todos, filter } = this.state;
    if (filter === "active")    return todos.filter(t => !t.done);
    if (filter === "completed") return todos.filter(t => t.done);
    return todos;
  }

  get remaining() {
    return this.state.todos.filter(t => !t.done).length;
  }

  addTodo() {
    const text = this.state.newTodo.trim();
    if (!text) return;
    this.state.todos.push({ id: this.state.nextId++, text, done: false });
    this.state.newTodo = "";
  }

  onKeyDown(ev) {
    if (ev.key === "Enter") this.addTodo();
  }

  toggleTodo(id) {
    const todo = this.state.todos.find(t => t.id === id);
    if (todo) todo.done = !todo.done;
  }

  deleteTodo(id) {
    const idx = this.state.todos.findIndex(t => t.id === id);
    if (idx !== -1) this.state.todos.splice(idx, 1);
  }

  static template = xml`
    <div>
      <h1>πŸ¦‰ OWL Todo App</h1>

      <input type="text" t-model="state.newTodo" t-on-keydown="onKeyDown"
             placeholder="What needs to be done?"/>
      <button t-on-click="addTodo" style="margin-bottom:1rem;">Add</button>

      <div class="filters">
        <button t-att-class="{ 'active-filter': state.filter === 'all' }"
                t-on-click="() => state.filter = 'all'">All</button>
        <button t-att-class="{ 'active-filter': state.filter === 'active' }"
                t-on-click="() => state.filter = 'active'">Active</button>
        <button t-att-class="{ 'active-filter': state.filter === 'completed' }"
                t-on-click="() => state.filter = 'completed'">Completed</button>
      </div>

      <ul style="list-style:none;padding:0;">
        <li t-foreach="filteredTodos" t-as="todo" t-key="todo.id"
            t-att-class="{ 'todo-item': true, done: todo.done }">
          <span class="todo-text" t-on-click="() => toggleTodo(todo.id)">
            <t t-esc="todo.text"/>
          </span>
          <button class="delete-btn" t-on-click="() => deleteTodo(todo.id)">βœ•</button>
        </li>
      </ul>

      <p t-if="filteredTodos.length === 0" style="color:#999;text-align:center;">
        No tasks here!
      </p>

      <p style="color:#666;font-size:0.9rem;">
        <t t-esc="remaining"/> task(s) remaining
      </p>
    </div>
  `;
}

mount(TodoApp, document.getElementById("app"));

What You Used in This App

OWL FeatureWhere usedWhat it does
useState()this.state = useState({...})Makes the state object reactive β€” any mutation triggers re-render
t-modelInput for new todo textTwo-way binding between input value and state.newTodo
t-on-clickAdd, toggle, delete, filtersAttaches click event listener to a DOM element
t-on-keydownInput fieldListens for keyboard events (Enter key)
t-foreach / t-as / t-keyTodo listLoops over filteredTodos, renders one <li> per item
t-att-classTodo items, filter buttonsConditionally applies CSS classes based on state
t-ifEmpty state messageConditionally renders the "No tasks here!" paragraph
t-escTodo text, remaining countRenders a value safely (HTML-escaped)
JS getterfilteredTodos, remainingComputed properties β€” derived from state, re-evaluated on render
⚠️
Important: t-key is required in t-foreach

t-key gives each list item a stable identity so OWL can efficiently diff the DOM when items are added, removed, or reordered. Always use a unique, stable value from your data (like an id field) β€” never use todo_index (the loop index) as the key when items can be deleted or reordered, as this causes subtle rendering bugs.

πŸ“‹ Summary

  • A real OWL JS app consists of: reactive state (useState), a template with t-* directives, and class methods that respond to user events.
  • t-model provides two-way binding between form inputs and reactive state.
  • t-foreach + t-key renders lists β€” always provide a stable unique key.
  • t-att-class conditionally applies CSS classes based on JavaScript expressions.
  • JavaScript getters (like get filteredTodos()) act as computed properties β€” derived from reactive state and automatically fresh each render.
  • Direct mutation of useState objects (todo.done = !todo.done) is the OWL way β€” no setter function needed.

πŸ‹οΈ Exercise

Extend the todo app with these additional features:

  1. Add a "Clear completed" button that removes all completed todos at once. Only show the button when there is at least one completed todo (use t-if).
  2. Add a priority field to each todo (priority: 'normal'). Show a star ⭐ button next to each item. Clicking it toggles priority between 'high' and 'normal'. Use t-att-class to highlight high-priority items.
  3. Challenge: Add inline editing β€” double-clicking a todo text puts it into edit mode (show an input pre-filled with the current text). Pressing Enter or clicking away saves the edit.