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;
}
}
<!-- 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 synct-on-keydown.enter— keyboard modifier fires only on Enter key() => toggleTodo(todo.id)— arrow function capturestodo.idfrom loop scopet-att-class="{ done: todo.done }"— object syntax adds CSS class when truthyget 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
}
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
- Add drag-and-drop reordering using the HTML5 drag API and
t-on-dragstart/t-on-drop. - Add inline editing: double-click a todo text to switch it to an
<input>, save on blur or Enter. - Add priority levels (Low / Medium / High) stored on each todo object, shown as a colour tag.
- Replace localStorage with
indexedDBvia a customuseIndexedDBhook.
🔥 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?
Related Topics
FAQ
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.
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).
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.
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".

