Enable Dark Mode!
how-to-use-the-interaction-class-in-odoo-19-effectively.jpg
By: Aswin AK

How to Use the Interaction Class in Odoo 19 Effectively

Technical Odoo 19 Odoo Enterprises Odoo Community

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:

  1. Constructor - Instance created (don't override constructor)
  2. setup() - Initialize properties and state
  3. willStart() - Async preparation work
  4. start() - Interaction is ready, DOM is interactive
  5. 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.


If you need any assistance in odoo, we are online, please chat with us.



0
Comments



Leave a comment



whatsapp_icon
location

Calicut

Cybrosys Technologies Pvt. Ltd.
Neospace, Kinfra Techno Park
Kakkancherry, Calicut
Kerala, India - 673635

location

Kochi

Cybrosys Technologies Pvt. Ltd.
1st Floor, Thapasya Building,
Infopark, Kakkanad,
Kochi, India - 682030.

location

Bangalore

Cybrosys Techno Solutions
The Estate, 8th Floor,
Dickenson Road,
Bangalore, India - 560042

Send Us A Message