Odoo 19 introduces a revolutionary new approach to website customization with the Interaction class. This powerful framework provides a structured, lifecycle-based system for creating dynamic, interactive web components without the overhead of full Owl components. Think of it as a lightweight, DOM-focused alternative that integrates seamlessly with Odoo's web framework.
In this comprehensive guide, we'll explore the Interaction class from the ground up, understand its architecture, and build practical examples that demonstrate its capabilities.
What is the Interaction Class?
The Interaction class is a base class designed to describe and manage user interactions on Odoo websites. It provides:
- Lifecycle management - Set up, start, and destroy hooks similar to component lifecycles
- Dynamic content binding - Declarative syntax for event handlers, attributes, and content updates
- Service integration - Direct access to Odoo's service layer
- Automatic cleanup - Built-in memory management and listener removal
- Helper utilities - Debouncing, throttling, async handling, and more
Unlike traditional JavaScript that scatters event listeners and DOM manipulation throughout your code, Interactions provide a cohesive, maintainable structure.
Core Architecture
The Selector System
Every Interaction targets specific DOM elements using CSS selectors:
export class MyInteraction extends Interaction {
static selector = ".my-element";
static selectorHas = ".required-child"; // Only match if contains this
static selectorNotHas = ".excluded-child"; // Skip if contains this
}When the website framework initializes, it automatically creates an instance for each matching element.
The Lifecycle
Interactions follow a well-defined lifecycle:
- Constructor - Instance created (don't override constructor)
- setup() - Initialize properties and state
- willStart() - Async preparation work
- start() - Interaction is ready, DOM is interactive
- destroy() - Cleanup when interaction is removed
export class CounterInteraction extends Interaction {
static selector = ".counter";
setup() {
this.count = 0;
console.log("Counter initialized");
}
async willStart() {
// Load initial count from server if needed
const data = await this.env.services.rpc("/api/get_count");
this.count = data.count;
}
start() {
console.log("Counter is ready with count:", this.count);
}
destroy() {
console.log("Counter being destroyed");
}
}Dynamic Content: The Heart of Interactions
The dynamicContent property is where the magic happens. It uses a declarative syntax inspired by Owl templates but designed for DOM manipulation:
dynamicContent = {
".increment-btn": {
"t-on-click": () => this.increment()
},
".counter-display": {
"t-out": () => this.count,
"t-att-class": () => ({
"text-success": this.count > 0,
"text-danger": this.count < 0
})
},
"_root": {
"t-att-data-count": () => this.count
}
};Available Directives
t-on-[event] - Event handlers
"t-on-click": (ev) => this.handleClick(ev),
"t-on-input": (ev) => this.handleInput(ev),
t-att-[attribute] - Dynamic attributes
"t-att-class": () => ({ "active": this.isActive }),
"t-att-style": () => ({ color: this.color }),
"t-att-disabled": () => this.isDisabled,
"t-att-href": () => `/page/${this.pageId}`,t-out - Dynamic text content
"t-out": () => this.displayText,
t-component - Mount Owl components
"t-component": () => [MyComponent, { prop: this.value }],Special Selectors
- _root - The interaction's main element (this.el)
- _body - Document body
- _window - Window object
- _document - Document object
Practical Example 1: Interactive Todo List
Let's build a functional todo list to demonstrate key concepts:
import { Interaction } from "@website/js/content/interaction";
import { registry } from "@web/core/registry";
export class TodoListInteraction extends Interaction {
static selector = ".todo-list-widget";
setup() {
this.todos = [];
this.inputValue = "";
this.filter = "all"; // all, active, completed
}
async willStart() {
// Load saved todos from localStorage (or server)
const saved = localStorage.getItem("todos");
if (saved) {
this.todos = JSON.parse(saved);
}
}
get filteredTodos() {
if (this.filter === "active") {
return this.todos.filter(t => !t.completed);
}
if (this.filter === "completed") {
return this.todos.filter(t => t.completed);
}
return this.todos;
}
get remainingCount() {
return this.todos.filter(t => !t.completed).length;
}
dynamicContent = {
".todo-input": {
"t-on-input": (ev) => {
this.inputValue = ev.target.value;
},
"t-on-keydown": (ev) => {
if (ev.key === "Enter" && this.inputValue.trim()) {
this.addTodo();
}
},
"t-att-value": () => this.inputValue,
},
".add-btn": {
"t-on-click": () => this.addTodo(),
"t-att-disabled": () => !this.inputValue.trim(),
},
".todo-count": {
"t-out": () => `${this.remainingCount} items left`,
},
".filter-all": {
"t-on-click": () => this.setFilter("all"),
"t-att-class": () => ({ active: this.filter === "all" }),
},
".filter-active": {
"t-on-click": () => this.setFilter("active"),
"t-att-class": () => ({ active: this.filter === "active" }),
},
".filter-completed": {
"t-on-click": () => this.setFilter("completed"),
"t-att-class": () => ({ active: this.filter === "completed" }),
},
};
start() {
this.renderTodos();
}
addTodo() {
if (!this.inputValue.trim()) return;
this.todos.push({
id: Date.now(),
text: this.inputValue.trim(),
completed: false,
});
this.inputValue = "";
this.saveTodos();
this.renderTodos();
}
toggleTodo(id) {
const todo = this.todos.find(t => t.id === id);
if (todo) {
todo.completed = !todo.completed;
this.saveTodos();
this.renderTodos();
}
}
deleteTodo(id) {
this.todos = this.todos.filter(t => t.id !== id);
this.saveTodos();
this.renderTodos();
}
setFilter(filter) {
this.filter = filter;
this.renderTodos();
}
renderTodos() {
const container = this.el.querySelector(".todo-items");
this.removeChildren(container, false);
this.filteredTodos.forEach(todo => {
const item = document.createElement("div");
item.className = `todo-item ${todo.completed ? "completed" : ""}`;
item.innerHTML = `
<input type="checkbox" ${todo.completed ? "checked" : ""}>
<span class="todo-text">${todo.text}</span>
<button class="delete-btn">Ă—</button>
`;
this.addListener(item.querySelector("input"), "change", () => {
this.toggleTodo(todo.id);
});
this.addListener(item.querySelector(".delete-btn"), "click", () => {
this.deleteTodo(todo.id);
});
this.insert(item, container);
});
}
saveTodos() {
localStorage.setItem("todos", JSON.stringify(this.todos));
}
destroy() {
this.saveTodos();
}
}
registry.category("public.interactions").add("your_module.key_name", TodoListInteraction);HTML Structure:
<div class="todo-list-widget">
<div class="todo-header">
<input type="text" class="todo-input" placeholder="What needs to be done?"/>
<button class="add-btn">Add</button>
</div>
<div class="todo-items"></div>
<div class="todo-footer">
<span class="todo-count"></span>
<div class="filters">
<button class="filter-all active">All</button>
<button class="filter-active">Active</button>
<button class="filter-completed">Completed</button>
</div>
</div>
</div>
Css:
/* --- Todo List Widget --- */
.todo-list-widget {
max-width: 400px;
margin: 30px auto;
background: #fff;
border-radius: 12px;
box-shadow: 0 4px 10px rgba(0, 0, 0, 0.08);
overflow: hidden;
font-family: "Inter", sans-serif;
transition: all 0.3s ease-in-out;
}
.todo-list-widget:hover {
transform: translateY(-2px);
}
/* Header */
.todo-header {
display: flex;
gap: 8px;
padding: 16px;
border-bottom: 1px solid #f0f0f0;
}
.todo-input {
flex: 1;
padding: 10px 12px;
border: 1px solid #d1d5db;
border-radius: 8px;
font-size: 14px;
outline: none;
transition: 0.3s;
}
.todo-input:focus {
border-color: #6366f1;
box-shadow: 0 0 0 3px rgba(99, 102, 241, 0.2);
}
.add-btn {
background: #6366f1;
color: #fff;
border: none;
padding: 10px 16px;
border-radius: 8px;
cursor: pointer;
font-weight: 500;
transition: background 0.3s;
}
.add-btn:hover {
background: #4f46e5;
}
/* Todo Items */
.todo-items {
max-height: 280px;
overflow-y: auto;
padding: 10px 16px;
}
.todo-items::-webkit-scrollbar {
width: 6px;
}
.todo-items::-webkit-scrollbar-thumb {
background: #d4d4d4;
border-radius: 4px;
}
.todo-item {
display: flex;
align-items: center;
justify-content: space-between;
background: #f9fafb;
border-radius: 8px;
padding: 10px 12px;
margin-bottom: 10px;
transition: all 0.2s;
}
.todo-item:hover {
background: #f3f4f6;
}
.todo-item label {
display: flex;
align-items: center;
gap: 10px;
cursor: pointer;
flex: 1;
}
.todo-item input[type="checkbox"] {
accent-color: #6366f1;
transform: scale(1.2);
}
.todo-item.completed label {
text-decoration: line-through;
color: #9ca3af;
}
/* Footer */
.todo-footer {
display: flex;
align-items: center;
justify-content: space-between;
padding: 12px 16px;
border-top: 1px solid #f0f0f0;
background: #fafafa;
}
.todo-count {
font-size: 13px;
color: #6b7280;
}
.filters {
display: flex;
gap: 6px;
}
.filters button {
border: none;
background: #e5e7eb;
color: #374151;
padding: 6px 10px;
border-radius: 6px;
font-size: 13px;
cursor: pointer;
transition: all 0.2s;
}
.filters button:hover {
background: #d1d5db;
}
.filters .active {
background: #6366f1;
color: #fff;
}
Helper Methods Deep Dive
1. waitFor() - Async Operation Management
async loadData() {
const promise = fetch("/api/data").then(r => r.json());
const data = await this.waitFor(promise);
// updateContent() is called automatically after this
this.data = data;
}2. debounced() - Debounce User Input
setup() {
this.searchDebounced = this.debounced(this.search, 300);
}
dynamicContent = {
".search-input": {
"t-on-input": (ev) => {
this.query = ev.target.value;
this.searchDebounced();
},
},
};
async search() {
const results = await fetch(`/search?q=${this.query}`);
this.results = await results.json();
}3. throttled() - Throttle Scroll/Resize Events
setup() {
this.handleScrollThrottled = this.throttled(this.handleScroll);
}
dynamicContent = {
"_window": {
"t-on-scroll": () => this.handleScrollThrottled(),
},
};
handleScroll() {
this.scrollY = window.scrollY;
// This runs at most once per animation frame
}4. locked() - Prevent Duplicate Submissions
dynamicContent = {
".submit-btn": {
"t-on-click": this.locked(async () => {
await this.submitForm();
}, true), // true adds loading animation
},
};
async submitForm() {
await this.waitFor(fetch("/submit", { method: "POST" }));
// Button stays disabled during this operation
}5. addListener() - Auto-cleanup Event Listeners
start() {
// Single element
this.addListener(this.el.querySelector(".btn"), "click", () => {
console.log("Clicked!");
});
// Multiple elements
const buttons = this.el.querySelectorAll(".action-btn");
this.addListener(buttons, "click", (ev) => {
this.handleAction(ev.target.dataset.action);
});
// Remove listener manually if needed
const removeListener = this.addListener(window, "resize", () => {
this.handleResize();
});
// Later: removeListener();
}6. renderAt() - Template Rendering
async loadComments() {
const comments = await this.waitFor(fetch("/comments").then(r => r.json()));
this.renderAt(
"website.comment_template",
{ comments },
this.el.querySelector(".comments-container"),
"beforeend",
(elements) => {
// Callback before insertion
elements.forEach(el => el.classList.add("fade-in"));
}
);
}Best Practices
1. Keep State in Setup
// Good
setup() {
this.items = [];
this.selectedId = null;
this.isLoading = false;
}
// Bad - Don't initialize in constructor
constructor(el, env, metadata) {
super(el, env, metadata);
this.items = []; // Don't do this
}
2. Use Dynamic Content for UI Updates
// Good - Declarative
dynamicContent = {
".status": {
"t-out": () => this.isLoading ? "Loading..." : "Ready",
"t-att-class": () => ({ "text-muted": this.isLoading }),
},
};
// Bad - Imperative
updateStatus() {
const el = this.el.querySelector(".status");
el.textContent = this.isLoading ? "Loading..." : "Ready";
el.classList.toggle("text-muted", this.isLoading);
}
3. Always Use Helper Methods for Async
// Good
async onClick() {
const data = await this.waitFor(this.fetchData());
this.data = data;
// updateContent() called automatically
}
// Bad
async onClick() {
const data = await this.fetchData();
this.data = data;
this.updateContent(); // Manual call needed
}
4. Register Cleanup for Side Effects
start() {
const interval = setInterval(() => {
this.refresh();
}, 5000);
this.registerCleanup(() => {
clearInterval(interval);
});
// Or use waitForTimeout which handles cleanup automatically
const timeoutId = this.waitForTimeout(() => {
this.doSomething();
}, 5000);
}Common Patterns
Loading States
setup() {
this.isLoading = false;
this.data = null;
this.error = null;
}
dynamicContent = {
".loading-spinner": {
"t-att-class": () => ({ "d-none": !this.isLoading }),
},
".content": {
"t-att-class": () => ({ "d-none": this.isLoading || this.error }),
},
".error-message": {
"t-att-class": () => ({ "d-none": !this.error }),
"t-out": () => this.error,
},
};
async loadData() {
this.isLoading = true;
this.error = null;
try {
this.data = await this.waitFor(rpc("/api/data");
} catch (e) {
this.error = "Failed to load data";
} finally {
this.isLoading = false;
}
}Conclusion
The Interaction class in Odoo 19 provides a powerful, structured approach to website customization. Its lifecycle management, declarative syntax, and built-in helpers make it easy to create maintainable, performant interactive components.
Key takeaways:
- Use dynamicContent for declarative UI updates
- Leverage helper methods (waitFor, debounced, throttled, etc.)
- Always clean up side effects (listeners are auto-cleaned)
- Keep state in setup(), not the constructor
- Use lifecycle methods appropriately
By mastering the Interaction class, you can build sophisticated website features with clean, maintainable code that integrates seamlessly with Odoo's architecture.
To read more about How to Use the Interaction Class with OWL Components in Odoo 19 Website, refer to our blog, How to Use the Interaction Class with OWL Components in Odoo 19 Website.