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.
Your First OWL App: Component + Template + State
A minimal OWL app is one component: a class with a template and some reactive state. Building it shows the three ideas you'll use in every OWL component — template, setup(), and useState.
import { Component, mount, useState, xml } from "@odoo/owl";
class Counter extends Component {
static template = xml`
<div>
Count: <t t-esc="state.value"/>
<button t-on-click="increment">+</button>
</div>`;
setup() {
this.state = useState({ value: 0 }); // reactive state
}
increment() {
this.state.value++; // mutating re-renders the UI
}
}
mount(Counter, document.body);
| Piece | Role |
|---|---|
static template | the QWeb markup |
setup() | init state & hooks (constructor replacement) |
useState | makes data reactive |
t-on-click | bind an event to a method |
The whole model in one example: the template describes the UI and reads from this (so state.value and methods are accessible); setup() runs once at creation to initialize state and hooks; useState wraps your data so that mutating it (this.state.value++) automatically triggers a re-render — you never manually touch the DOM. Events wire template to logic via t-on-click="increment", pointing at a method. Why setup() not constructor(): hooks like useState need OWL's internal context, which is ready when setup runs. The reactivity rule: only changes to a useState object re-render; assigning to a plain this.x won't update the UI. Mounting: standalone you call mount(Component, target); in Odoo the framework mounts it for you via the registry. Single root: a template must have one root element — wrap siblings in a <div> or <t>. Get comfortable with this counter and you understand the foundation of every OWL component.
🏋️ Practical 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.
🔥 Challenge Exercise
Build your first OWL app: a Root component with an inline XML template that renders a greeting, mounted to the page, then add a child component. Explain the anatomy of an OWL component (class + template + optional setup).
📋 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.
Interview Questions
- What are the parts of an OWL component?
- How do you define a template?
- How do you mount an app?
- How do you nest components?
- What is the
static templateproperty?
Related Topics
FAQ
No. OWL uses XML/QWeb templates (as tagged strings via xml`...` or .xml files), not JSX. Templates are compiled to render functions.
Yes — load the UMD build from a CDN and use the owl.Component/owl.mount globals. A bundler (Vite/webpack) is recommended for real projects.

