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.
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:
<!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:
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:
// 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="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.
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:
// 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:
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:
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 Feature | Where used | What it does |
|---|---|---|
useState() | this.state = useState({...}) | Makes the state object reactive β any mutation triggers re-render |
t-model | Input for new todo text | Two-way binding between input value and state.newTodo |
t-on-click | Add, toggle, delete, filters | Attaches click event listener to a DOM element |
t-on-keydown | Input field | Listens for keyboard events (Enter key) |
t-foreach / t-as / t-key | Todo list | Loops over filteredTodos, renders one <li> per item |
t-att-class | Todo items, filter buttons | Conditionally applies CSS classes based on state |
t-if | Empty state message | Conditionally renders the "No tasks here!" paragraph |
t-esc | Todo text, remaining count | Renders a value safely (HTML-escaped) |
| JS getter | filteredTodos, remaining | Computed properties β derived from state, re-evaluated on render |
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 witht-*directives, and class methods that respond to user events. t-modelprovides two-way binding between form inputs and reactive state.t-foreach+t-keyrenders lists β always provide a stable unique key.t-att-classconditionally 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
useStateobjects (todo.done = !todo.done) is the OWL way β no setter function needed.
ποΈ Exercise
Extend the todo app with these additional features:
- 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). - 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'. Uset-att-classto highlight high-priority items. - 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.