Advertisement
🦉 Project

OWL JS Todo App – Complete Project Walkthrough

A Todo App is the canonical first project for any UI framework — deceptively simple, but it exercises the full lifecycle: creating state, rendering lists, handling user input, filtering data, and persisting to storage. This walkthrough builds the app in four stages: skeleton component, CRUD operations, filter tabs, and local storage persistence. A final section shows the Odoo-compatible version backed by ORM calls.

⏱️ 45 min 🎯 Beginner 📅 Updated 2026

What You'll Build

The finished app has these features:

  • Add todos by typing and pressing Enter or clicking Add
  • Mark todos complete / incomplete with a checkbox
  • Delete individual todos
  • Filter by All / Active / Completed tabs
  • Show count of remaining active items
  • Clear all completed items in one click
  • Persist todos across page refreshes via localStorage

Project Structure

my_module/
├── static/src/
│   ├── components/
│   │   ├── TodoApp.js
│   │   ├── TodoApp.xml
│   │   ├── TodoItem.js
│   │   └── TodoItem.xml
│   └── index.js
└── __manifest__.py

Each component lives in its own .js + .xml pair. index.js registers the root and mounts it.

Stage 1 – Skeleton Component

Start with a static shell — no state, no interaction. Confirm the template renders before wiring up logic.

// TodoApp.js
/** @odoo-module **/
import { Component } from "@odoo/owl";

export class TodoApp extends Component {
  static template = "my_module.TodoApp";
}
<!-- TodoApp.xml -->
<templates>
  <t t-name="my_module.TodoApp">
    <div class="todo-app">
      <h1>My Todos</h1>

      <div class="todo-input-row">
        <input type="text" placeholder="What needs to be done?" />
        <button>Add</button>
      </div>

      <ul class="todo-list">
        <li class="todo-item">
          <input type="checkbox" />
          <span>Sample todo</span>
          <button class="delete-btn">✕</button>
        </li>
      </ul>

      <footer class="todo-footer">
        <span>1 item left</span>
        <div class="filters">
          <button class="active">All</button>
          <button>Active</button>
          <button>Completed</button>
        </div>
        <button>Clear completed</button>
      </footer>
    </div>
  </t>
</templates>

Stage 2 – State and CRUD

Add useState to hold the todo list and the current input value. Wire up add, toggle, and delete.

// TodoApp.js
/** @odoo-module **/
import { Component, useState } from "@odoo/owl";

export class TodoApp extends Component {
  static template = "my_module.TodoApp";

  setup() {
    this.state = useState({
      todos: [],
      newText: "",
      filter: "all",    // "all" | "active" | "completed"
    });
    this._nextId = 1;
  }

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

  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);
  }

  clearCompleted() {
    const active = this.state.todos.filter(t => !t.done);
    this.state.todos = active;
  }

  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 activeCount() {
    return this.state.todos.filter(t => !t.done).length;
  }
}
Advertisement
<!-- TodoApp.xml – Stage 2 -->
<templates>
  <t t-name="my_module.TodoApp">
    <div class="todo-app">
      <h1>My Todos</h1>

      <div class="todo-input-row">
        <input
          type="text"
          placeholder="What needs to be done?"
          t-model="state.newText"
          t-on-keydown.enter="addTodo"
        />
        <button t-on-click="addTodo">Add</button>
      </div>

      <ul class="todo-list">
        <li
          t-foreach="filteredTodos"
          t-as="todo"
          t-key="todo.id"
          t-att-class="{ done: todo.done }"
          class="todo-item"
        >
          <input
            type="checkbox"
            t-att-checked="todo.done"
            t-on-change="() => toggleTodo(todo.id)"
          />
          <span t-esc="todo.text" />
          <button class="delete-btn" t-on-click="() => deleteTodo(todo.id)">✕</button>
        </li>
      </ul>

      <footer class="todo-footer" t-if="state.todos.length">
        <span><t t-esc="activeCount" /> item<t t-if="activeCount !== 1">s</t> left</span>

        <div class="filters">
          <button
            t-foreach="['all','active','completed']"
            t-as="f"
            t-key="f"
            t-att-class="{ active: state.filter === f }"
            t-on-click="() => state.filter = f"
            t-esc="f"
          />
        </div>

        <button t-on-click="clearCompleted">Clear completed</button>
      </footer>
    </div>
  </t>
</templates>

Key Points

  • t-model="state.newText" — two-way binding keeps input and state in sync
  • t-on-keydown.enter — keyboard modifier fires only on Enter key
  • () => toggleTodo(todo.id) — arrow function captures todo.id from loop scope
  • t-att-class="{ done: todo.done }" — object syntax adds CSS class when truthy
  • get filteredTodos — computed getter, OWL re-evaluates when reactive state changes

Stage 3 – Extract TodoItem Component

When a list item grows past a few lines, extract it into a child component. This isolates re-renders: only the changed item re-renders, not the whole list.

// TodoItem.js
/** @odoo-module **/
import { Component } from "@odoo/owl";

export class TodoItem extends Component {
  static template = "my_module.TodoItem";

  static props = {
    todo: { type: Object },        // { id, text, done }
    onToggle: Function,
    onDelete: Function,
  };
}
<!-- TodoItem.xml -->
<templates>
  <t t-name="my_module.TodoItem">
    <li t-att-class="{ done: props.todo.done }" class="todo-item">
      <input
        type="checkbox"
        t-att-checked="props.todo.done"
        t-on-change="props.onToggle"
      />
      <span t-esc="props.todo.text" />
      <button class="delete-btn" t-on-click="props.onDelete">✕</button>
    </li>
  </t>
</templates>

Update TodoApp to use the child:

// TodoApp.js — add import and static components
import { TodoItem } from "./TodoItem";

export class TodoApp extends Component {
  static template = "my_module.TodoApp";
  static components = { TodoItem };
  // ... rest unchanged
}
<!-- TodoApp.xml — replace li with TodoItem -->
<TodoItem
  t-foreach="filteredTodos"
  t-as="todo"
  t-key="todo.id"
  todo="todo"
  onToggle="() => toggleTodo(todo.id)"
  onDelete="() => deleteTodo(todo.id)"
/>

Stage 4 – Local Storage Persistence

Use onMounted to load saved todos and a custom useEffect-style pattern via onPatched to save on every change.

// TodoApp.js — add persistence
import { Component, useState, onMounted, onPatched } from "@odoo/owl";

export class TodoApp extends Component {
  static template = "my_module.TodoApp";
  static components = { TodoItem };

  setup() {
    this.state = useState({
      todos: [],
      newText: "",
      filter: "all",
    });
    this._nextId = 1;

    onMounted(() => {
      const saved = localStorage.getItem("owl_todos");
      if (saved) {
        const data = JSON.parse(saved);
        this.state.todos = data.todos;
        this._nextId = data.nextId ?? data.todos.length + 1;
      }
    });

    onPatched(() => {
      localStorage.setItem("owl_todos", JSON.stringify({
        todos: this.state.todos,
        nextId: this._nextId,
      }));
    });
  }

  // ... addTodo, toggleTodo, deleteTodo, clearCompleted, getters unchanged
}
Why onPatched and not onMounted? onMounted fires once after first render. onPatched fires after every re-render caused by state change — perfect for "save on change" behaviour. There is no useEffect in OWL; this pair (onMounted + onPatched) covers that use case.

Odoo-Integrated Version

In a real Odoo module, todos live in a database model. Replace localStorage with ORM calls.

// TodoApp.js — Odoo version
/** @odoo-module **/
import { Component, useState, onWillStart } from "@odoo/owl";
import { useService } from "@web/core/utils/hooks";

export class TodoApp extends Component {
  static template = "my_module.TodoApp";

  setup() {
    this.orm = useService("orm");
    this.notification = useService("notification");

    this.state = useState({
      todos: [],
      newText: "",
      filter: "all",
      loading: true,
    });

    onWillStart(async () => {
      await this.loadTodos();
    });
  }

  async loadTodos() {
    this.state.loading = true;
    try {
      const records = await this.orm.searchRead(
        "project.task",
        [["user_ids", "=", this.env.user.userId]],
        ["id", "name", "stage_id"],
        { limit: 100 }
      );
      this.state.todos = records.map(r => ({
        id: r.id,
        text: r.name,
        done: r.stage_id[1] === "Done",
      }));
    } finally {
      this.state.loading = false;
    }
  }

  async addTodo() {
    const text = this.state.newText.trim();
    if (!text) return;
    const id = await this.orm.create("project.task", [{ name: text }]);
    this.state.todos.push({ id, text, done: false });
    this.state.newText = "";
    this.notification.add("Task created", { type: "success" });
  }

  async toggleTodo(id) {
    const todo = this.state.todos.find(t => t.id === id);
    if (!todo) return;
    todo.done = !todo.done;
    await this.orm.write("project.task", [id], {
      stage_id: todo.done ? /* done stage ID */ 4 : /* todo stage ID */ 1,
    });
  }

  async deleteTodo(id) {
    await this.orm.unlink("project.task", [id]);
    const idx = this.state.todos.findIndex(t => t.id === id);
    if (idx !== -1) this.state.todos.splice(idx, 1);
  }

  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 activeCount() {
    return this.state.todos.filter(t => !t.done).length;
  }
}

Template for Odoo Version

<templates>
  <t t-name="my_module.TodoApp">
    <div class="todo-app">
      <t t-if="state.loading">
        <div class="loading">Loading tasks…</div>
      </t>
      <t t-else="">
        <!-- same UI structure as standalone version -->
        <div class="todo-input-row">
          <input
            type="text"
            placeholder="New task…"
            t-model="state.newText"
            t-on-keydown.enter="addTodo"
          />
          <button t-on-click="addTodo">Add</button>
        </div>

        <ul class="todo-list">
          <li
            t-foreach="filteredTodos"
            t-as="todo"
            t-key="todo.id"
            t-att-class="{ done: todo.done }"
            class="todo-item"
          >
            <input
              type="checkbox"
              t-att-checked="todo.done"
              t-on-change="() => toggleTodo(todo.id)"
            />
            <span t-esc="todo.text" />
            <button t-on-click="() => deleteTodo(todo.id)">✕</button>
          </li>
        </ul>

        <footer class="todo-footer" t-if="state.todos.length">
          <span><t t-esc="activeCount" /> left</span>
          <div class="filters">
            <button t-foreach="['all','active','completed']" t-as="f" t-key="f"
              t-att-class="{ active: state.filter === f }"
              t-on-click="() => state.filter = f"
              t-esc="f"
            />
          </div>
        </footer>
      </t>
    </div>
  </t>
</templates>

Registering the App

// index.js — standalone (mount to DOM)
/** @odoo-module **/
import { mount } from "@odoo/owl";
import { TodoApp } from "./components/TodoApp";

mount(TodoApp, document.getElementById("app"), {
  dev: true,
  translateFn: t => t,
});
// index.js — Odoo client action registration
/** @odoo-module **/
import { registry } from "@web/core/registry";
import { TodoApp } from "./components/TodoApp";

registry.category("actions").add("my_module.todo_app", TodoApp);

CSS Styles

.todo-app {
  max-width: 480px;
  margin: 40px auto;
  font-family: sans-serif;
}

.todo-input-row {
  display: flex;
  gap: 8px;
  margin-bottom: 16px;
}

.todo-input-row input {
  flex: 1;
  padding: 10px 14px;
  border: 1px solid #ddd;
  border-radius: 4px;
  font-size: 14px;
}

.todo-list {
  list-style: none;
  padding: 0;
  margin: 0;
}

.todo-item {
  display: flex;
  align-items: center;
  gap: 10px;
  padding: 10px 0;
  border-bottom: 1px solid #f0f0f0;
}

.todo-item.done span {
  text-decoration: line-through;
  color: #aaa;
}

.todo-footer {
  display: flex;
  justify-content: space-between;
  align-items: center;
  padding-top: 12px;
  font-size: 13px;
  color: #666;
}

.filters button {
  border: none;
  background: transparent;
  cursor: pointer;
  padding: 2px 8px;
  border-radius: 3px;
}

.filters button.active {
  border: 1px solid #2271b1;
  color: #2271b1;
}

🏋️ Practical Exercise

  1. Add drag-and-drop reordering using the HTML5 drag API and t-on-dragstart / t-on-drop.
  2. Add inline editing: double-click a todo text to switch it to an <input>, save on blur or Enter.
  3. Add priority levels (Low / Medium / High) stored on each todo object, shown as a colour tag.
  4. Replace localStorage with indexedDB via a custom useIndexedDB hook.

🔥 Challenge Exercise

Build a Todo app: an input that adds items to reactive state, a t-foreach list with t-key, toggling completion, and deleting items. Explain list rendering with keys, two-way input binding, and updating state reactively.

📋 Summary

  • 4-stage build: skeleton → state/CRUD → child component extraction → persistence
  • useState array mutations: push/splice on reactive array trigger re-render
  • Computed getters: get filteredTodos() re-runs automatically on state change
  • onMounted + onPatched: load once, save on every update
  • Child component isolation: extracting TodoItem reduces unnecessary re-renders
  • Odoo version: swap localStorage for orm.searchRead/create/write/unlink

Interview Questions

  • How do you manage a list in OWL state?
  • Why do list items need a key?
  • How do you add and remove items reactively?
  • How do you bind the new-todo input?
  • How do you toggle an item’s state?

FAQ

Why use push/splice instead of replacing the array? +

OWL's Proxy watches mutations on existing objects. this.state.todos.push(item) mutates the proxied array and triggers re-render. Assigning a new array (this.state.todos = [...]) also works because the proxy intercepts property assignments on the parent state object. Both are valid; mutation is slightly more performant for large lists.

Can I use onWillStart instead of onMounted for localStorage? +

Yes. onWillStart runs before the first render so the loaded data is available immediately — no flash of empty state. Use onWillStart when loading is async (ORM, fetch). Use onMounted when you need access to the DOM or when synchronous (localStorage.getItem is synchronous).

How do I share todo state between sibling components? +

Lift state to the common parent and pass it down as props + callback props. For app-wide state, create a custom service registered in the Odoo service registry and access it via useService. Avoid global variables or module-level state.

The Odoo stage_id is hard-coded — how do I make it dynamic? +

Load available stages in onWillStart using orm.searchRead("project.task.type", [], ["id","name"]). Store them in state and let the user pick a stage when creating a task. Map "done" to the stage whose sequence is highest or whose name contains "Done".