Skip to Content
  • Home
  • Book
  • Blog
  • About us
  • Help
  • Contact us
  •  [email protected]
  • Sign in
  • English (US) Español (BO)
  • Simplify It S.R.L.
    • Contact Us
Simplify It S.R.L.
      • Home
      • Book
      • Blog
      • About us
      • Help
      • Contact us
    •  [email protected]
    • English (US) Español (BO)
    • Sign in
    • Contact Us

    Chapter 9: Handling Events: Child-to-Parent Communication

  • All Blogs
  • OWL Book
  • Chapter 9: Handling Events: Child-to-Parent Communication
  • August 25, 2026 by
    Chapter 9: Handling Events: Child-to-Parent Communication
    Grover Menacho

    Why this chapter? I've inherited more than one addon where a child widget reached straight into its parent's state to "fix" something, and every one of them turned brittle the moment someone changed the parent. Callback props are the disciplined alternative. What is Odoo trying to solve with this? OWL removed the old trigger/event-bus system from OWL 1 on purpose — Odoo needed child-to-parent communication that's traceable from the parent's own template, not scattered across event listeners you have to hunt down. Real-world application: A TodoItem telling its TodoList "delete me" or a table row telling its parent "I was clicked" are the same shape as a form field widget telling the form view "my value changed" — the pattern you'll use constantly once you start building real Odoo UI.

    In Chapter 8, we established the golden rule of props: data flows one way, from parent down to child. A child component should never modify its own props directly. This creates predictable, maintainable applications where you always know where data changes originate.

    But this raises an important question: what happens when a child component needs to communicate back to its parent? What if a user clicks a "Delete" button inside a TodoItem component? The child can't delete the data itself because that data belongs to the parent and was passed down as a prop.

    This is where the second half of the communication loop comes in: callback props.

    While data flows down through props, communication flows up through function calls. The parent passes a function to the child as a prop; when something happens inside the child (a click, a submit, an error), the child simply calls that function. The parent decides what to do with the information. This completes the data flow cycle while keeping the parent firmly in control of the application state.

    Coming from OWL 1.x? The old framework had a trigger() mechanism that dispatched custom events up the component tree, which parents listened to with t-on-event-name on the component tag. That mechanism was removed in OWL 2.0. In OWL 2.0, t-on- works only on real DOM elements (for native events like click and input), and child-to-parent communication is done exclusively with callback props. If you see this.trigger("some-event") in old code, that's legacy OWL 1 (we'll cover migrating it in Chapter 17).


    Understanding the Communication Flow

    Let's visualize how this bidirectional communication works:

    Parent Component
    |-- State: { todos: [...], users: [...] }
    |-- Data flows DOWN via props ↓
    |-- Callbacks flow DOWN via props ↓
    |
    Child Component
    |-- Receives: data props (read-only) + callback props
    |-- User interaction occurs
    |-- Child CALLS the callback ↑
    |
    Parent Component
    |-- Callback runs in the parent
    |-- Updates its own state
    |-- New state flows DOWN as props ↓
    

    This pattern ensures that: - Parents control the data and business logic - Children remain pure and reusable - Data flow is predictable and easy to debug - Components are loosely coupled and maintainable


    Basic Callback Pattern

    Let's start with a simple example. We'll create a TodoItem component that can notify its parent when it needs to be deleted.

    The Child Component: TodoItem

    TodoItem JavaScript (todo_item.js):

    import { Component } from "@odoo/owl";
    
    export class TodoItem extends Component {
      static template = "my_module.TodoItem";
    
      static props = {
        todo: {
          type: Object,
          shape: {
            id: Number,
            text: String,
            completed: Boolean,
            priority: { type: String, optional: true },
            dueDate: { type: String, optional: true }
          }
        },
        // Callback props: functions the parent gives us to "call home"
        onDelete: { type: Function },
        onToggle: { type: Function },
        onEdit: { type: Function, optional: true },
        canEdit: { type: Boolean, optional: true },
        canDelete: { type: Boolean, optional: true },
        showPriority: { type: Boolean, optional: true }
      };
    
      static defaultProps = {
        canEdit: true,
        canDelete: true,
        showPriority: false
      };
    
      // Event handler for delete button
      onDeleteClick() {
        console.log("TodoItem: Delete button clicked, calling onDelete callback");
    
        // Call the parent's function with a payload object
        this.props.onDelete({
          todoId: this.props.todo.id,
          todoText: this.props.todo.text
        });
      }
    
      // Event handler for toggle completion
      onToggleClick() {
        console.log("TodoItem: Toggle clicked, calling onToggle callback");
    
        this.props.onToggle({
          todoId: this.props.todo.id,
          currentStatus: this.props.todo.completed
        });
      }
    
      // Event handler for edit request
      onEditClick() {
        console.log("TodoItem: Edit clicked, calling onEdit callback");
    
        // Optional callbacks may be undefined — use optional chaining
        this.props.onEdit?.({
          todoId: this.props.todo.id,
          currentText: this.props.todo.text
        });
      }
    
      // Computed properties for styling
      get todoClasses() {
        const classes = ['todo-item'];
        if (this.props.todo.completed) {
          classes.push('todo-completed');
        }
        if (this.props.todo.priority === 'high') {
          classes.push('todo-high-priority');
        }
        return classes.join(' ');
      }
    
      get priorityBadgeClass() {
        const priorityClasses = {
          low: 'badge-secondary',
          medium: 'badge-warning',
          high: 'badge-danger'
        };
        return priorityClasses[this.props.todo.priority] || 'badge-secondary';
      }
    }
    

    TodoItem Template (todo_item.xml):

    <?xml version="1.0" encoding="UTF-8"?>
    <templates xml:space="preserve">
    
        <t t-name="my_module.TodoItem" owl="1">
            <div t-att-class="todoClasses" class="d-flex align-items-center p-3 border rounded mb-2">
    
                <!-- Toggle Completion Checkbox -->
                <div class="todo-toggle me-3">
                    <input type="checkbox"
                           class="form-check-input"
                           t-att-checked="props.todo.completed"
                           t-on-click="onToggleClick"/>
                </div>
    
                <!-- Todo Content -->
                <div class="todo-content flex-grow-1">
                    <div class="d-flex justify-content-between align-items-start">
                        <div class="todo-text">
                            <span t-att-class="props.todo.completed ? 'text-decoration-line-through text-muted' : ''">
                                <t t-esc="props.todo.text"/>
                            </span>
    
                            <!-- Priority Badge -->
                            <t t-if="props.showPriority and props.todo.priority">
                                <span t-att-class="'badge ms-2 ' + priorityBadgeClass">
                                    <t t-esc="props.todo.priority"/>
                                </span>
                            </t>
                        </div>
    
                        <!-- Due Date -->
                        <t t-if="props.todo.dueDate">
                            <small class="text-muted">
                                Due: <t t-esc="props.todo.dueDate"/>
                            </small>
                        </t>
                    </div>
                </div>
    
                <!-- Action Buttons -->
                <div class="todo-actions ms-3">
                    <t t-if="props.canEdit">
                        <button class="btn btn-sm btn-outline-primary me-1"
                                t-on-click="onEditClick">
                            <i class="fa fa-edit"></i>
                        </button>
                    </t>
    
                    <t t-if="props.canDelete">
                        <button class="btn btn-sm btn-outline-danger"
                                t-on-click="onDeleteClick">
                            <i class="fa fa-trash"></i>
                        </button>
                    </t>
                </div>
            </div>
        </t>
    
    </templates>
    

    Notice that t-on-click is used on real DOM elements (the checkbox and buttons)—that's exactly what t-on- is for in OWL 2.0: listening to native browser events. The bridge to the parent is the callback prop.

    The Parent Component: TodoList

    Now let's create the parent component that provides those callbacks:

    TodoList JavaScript (todo_list.js):

    import { Component, useState } from "@odoo/owl";
    import { TodoItem } from "../todo_item/todo_item";
    
    export class TodoList extends Component {
      static template = "my_module.TodoList";
      static components = { TodoItem };
    
      setup() {
        this.state = useState({
          todos: [
            {
              id: 1,
              text: "Learn OWL basics",
              completed: true,
              priority: "medium",
              dueDate: "2024-03-20"
            },
            {
              id: 2,
              text: "Build a todo app",
              completed: false,
              priority: "high",
              dueDate: "2024-03-25"
            },
            {
              id: 3,
              text: "Master component communication",
              completed: false,
              priority: "low",
              dueDate: "2024-03-30"
            }
          ],
    
          editingTodoId: null,
          newTodoText: "",
          showCompleted: true,
          showPriority: true
        });
      }
    
      // Callback: child asked us to delete a todo
      onTodoDelete({ todoId, todoText }) {
        console.log(`TodoList: onTodoDelete called for ID ${todoId}`);
    
        // Show confirmation (optional)
        if (confirm(`Are you sure you want to delete "${todoText}"?`)) {
          // Update parent state - this will cause re-render
          this.state.todos = this.state.todos.filter(todo => todo.id !== todoId);
    
          console.log(`TodoList: Todo ${todoId} deleted successfully`);
        }
      }
    
      // Callback: child asked us to toggle completion
      onTodoToggle({ todoId }) {
        console.log(`TodoList: onTodoToggle called for ID ${todoId}`);
    
        // Find and update the todo
        const todo = this.state.todos.find(t => t.id === todoId);
        if (todo) {
          todo.completed = !todo.completed;
          console.log(`TodoList: Todo ${todoId} completion status changed to ${todo.completed}`);
        }
      }
    
      // Callback: child asked us to start editing a todo
      onTodoEdit({ todoId, currentText }) {
        console.log(`TodoList: onTodoEdit called for ID ${todoId}`);
    
        // Enter edit mode
        this.state.editingTodoId = todoId;
        this.state.newTodoText = currentText;
      }
    
      // Local methods for managing todos
      addNewTodo() {
        if (!this.state.newTodoText.trim()) return;
    
        const newTodo = {
          id: Math.max(...this.state.todos.map(t => t.id), 0) + 1,
          text: this.state.newTodoText.trim(),
          completed: false,
          priority: "medium",
          dueDate: null
        };
    
        this.state.todos.push(newTodo);
        this.state.newTodoText = "";
      }
    
      saveEdit() {
        if (!this.state.newTodoText.trim()) return;
    
        const todo = this.state.todos.find(t => t.id === this.state.editingTodoId);
        if (todo) {
          todo.text = this.state.newTodoText.trim();
        }
    
        this.cancelEdit();
      }
    
      cancelEdit() {
        this.state.editingTodoId = null;
        this.state.newTodoText = "";
      }
    
      toggleShowCompleted() {
        this.state.showCompleted = !this.state.showCompleted;
      }
    
      toggleShowPriority() {
        this.state.showPriority = !this.state.showPriority;
      }
    
      // Computed properties
      get visibleTodos() {
        if (this.state.showCompleted) {
          return this.state.todos;
        }
        return this.state.todos.filter(todo => !todo.completed);
      }
    
      get completedCount() {
        return this.state.todos.filter(todo => todo.completed).length;
      }
    
      get totalCount() {
        return this.state.todos.length;
      }
    }
    

    TodoList Template (todo_list.xml):

    <?xml version="1.0" encoding="UTF-8"?>
    <templates xml:space="preserve">
    
        <t t-name="my_module.TodoList" owl="1">
            <div class="todo-list-container p-4">
    
                <!-- Header -->
                <div class="todo-header mb-4">
                    <div class="d-flex justify-content-between align-items-center">
                        <div>
                            <h2>My Todo List</h2>
                            <p class="text-muted mb-0">
                                <t t-esc="completedCount"/> completed of <t t-esc="totalCount"/> todos
                            </p>
                        </div>
    
                        <!-- View Controls -->
                        <div class="view-controls">
                            <button class="btn btn-sm me-2"
                                    t-att-class="state.showCompleted ? 'btn-primary' : 'btn-outline-primary'"
                                    t-on-click="toggleShowCompleted">
                                <i class="fa fa-eye"></i>
                                <t t-esc="state.showCompleted ? 'Hide Completed' : 'Show All'"/>
                            </button>
    
                            <button class="btn btn-sm btn-outline-secondary"
                                    t-att-class="state.showPriority ? 'active' : ''"
                                    t-on-click="toggleShowPriority">
                                <i class="fa fa-flag"></i>
                                Priority
                            </button>
                        </div>
                    </div>
                </div>
    
                <!-- Add New Todo Form -->
                <div class="add-todo-form mb-4 p-3 bg-light rounded">
                    <div class="row">
                        <div class="col">
                            <input type="text"
                                   class="form-control"
                                   placeholder="Add a new todo..."
                                   t-model="state.newTodoText"
                                   t-on-keyup.enter="addNewTodo"/>
                        </div>
                        <div class="col-auto">
                            <button class="btn btn-success" t-on-click="addNewTodo">
                                <i class="fa fa-plus"></i>
                                Add Todo
                            </button>
                        </div>
                    </div>
                </div>
    
                <!-- Edit Form -->
                <t t-if="state.editingTodoId">
                    <div class="edit-todo-form mb-4 p-3 bg-warning bg-opacity-10 rounded">
                        <h5>Edit Todo</h5>
                        <div class="row">
                            <div class="col">
                                <input type="text"
                                       class="form-control"
                                       t-model="state.newTodoText"
                                       t-on-keyup.enter="saveEdit"/>
                            </div>
                            <div class="col-auto">
                                <button class="btn btn-success me-2" t-on-click="saveEdit">
                                    <i class="fa fa-check"></i>
                                    Save
                                </button>
                                <button class="btn btn-secondary" t-on-click="cancelEdit">
                                    <i class="fa fa-times"></i>
                                    Cancel
                                </button>
                            </div>
                        </div>
                    </div>
                </t>
    
                <!-- Todo Items -->
                <div class="todo-items">
                    <t t-if="visibleTodos.length > 0">
                        <t t-foreach="visibleTodos" t-as="todo" t-key="todo.id">
                            <!-- This is where we hand our callbacks to the child -->
                            <TodoItem
                                todo="todo"
                                canEdit="true"
                                canDelete="true"
                                showPriority="state.showPriority"
                                onDelete.bind="onTodoDelete"
                                onToggle.bind="onTodoToggle"
                                onEdit.bind="onTodoEdit"
                            />
                        </t>
                    </t>
    
                    <!-- Empty State -->
                    <t t-else="">
                        <div class="empty-state text-center py-5">
                            <i class="fa fa-tasks fa-3x text-muted mb-3"></i>
                            <h4 class="text-muted">No todos to show</h4>
                            <p class="text-muted">
                                <t t-if="!state.showCompleted">
                                    All todos are completed! Toggle "Show All" to see them.
                                </t>
                                <t t-else="">
                                    Add your first todo above to get started.
                                </t>
                            </p>
                        </div>
                    </t>
                </div>
            </div>
        </t>
    
    </templates>
    

    The .bind Suffix: Small Detail, Big Deal

    Look closely at how the callbacks are passed:

    <TodoItem onDelete.bind="onTodoDelete"/>
    

    The .bind suffix tells OWL to bind the function to the parent component before passing it down. Without it, when the child calls this.props.onDelete(...), the this inside onTodoDelete would be undefined and this.state would crash.

    You have three equivalent options—pick one and be consistent:

    <!-- Option 1 (recommended): the .bind suffix -->
    <TodoItem onDelete.bind="onTodoDelete"/>
    
    <!-- Option 2: an inline arrow function (binds `this` automatically) -->
    <TodoItem onDelete="(payload) => this.onTodoDelete(payload)"/>
    
    // Option 3: bind manually in setup()
    setup() {
      this.onTodoDelete = this.onTodoDelete.bind(this);
    }
    

    The .bind suffix is the idiomatic OWL 2.0 way and what you'll see throughout the Odoo codebase.


    Advanced Callback Patterns

    1. Native Events and Stopping Propagation

    t-on- on DOM elements supports useful modifiers:

    <!-- .stop calls event.stopPropagation() for you -->
    <button t-on-click.stop="onDeleteClick">Delete</button>
    
    <!-- .prevent calls event.preventDefault() -->
    <form t-on-submit.prevent="onSubmit">...</form>
    
    <!-- .enter fires only for the Enter key -->
    <input t-on-keyup.enter="addNewTodo"/>
    

    Use these to control the native DOM event; then call your callback prop as usual.

    2. Conditional Callback Invocation

    // Only call the callback if certain conditions are met
    onDeleteClick() {
      if (this.props.todo.completed) {
        this.props.onDelete({ todoId: this.props.todo.id });
      } else {
        this.props.onConfirmDelete?.({
          todoId: this.props.todo.id,
          message: "This todo is not completed. Are you sure?"
        });
      }
    }
    

    3. Async Callbacks

    Callbacks can be async, which lets the child await the parent's work (for example, to show a spinner while the parent saves):

    // Child
    async onSaveClick() {
      this.state.saving = true;
      try {
        await this.props.onSave({ data: this.state.formData });
      } finally {
        this.state.saving = false;
      }
    }
    

    4. Validation in the Parent

    // Parent validates the payload before processing
    onTodoUpdate({ todoId, newText }) {
      // Validate the data
      if (!todoId || typeof newText !== 'string' || newText.trim().length === 0) {
        console.error("Invalid todo update:", { todoId, newText });
        return;
      }
    
      // Proceed with update
      const todo = this.state.todos.find(t => t.id === todoId);
      if (todo) {
        todo.text = newText.trim();
      }
    }
    

    Callback Naming Conventions

    Following consistent naming conventions makes your code more maintainable:

    // In the child's props definition: "on" + what happened, camelCase
    static props = {
      onTodoCreated: { type: Function },
      onUserSelected: { type: Function },
      onFormSubmitted: { type: Function },
      onError: { type: Function, optional: true },
    };
    

    Best Practices:

    1. Prefix with on: onDelete, onSave, onUserSelected—it instantly reads as "callback"
    2. Use camelCase: callback props are regular props, so they follow prop naming (onTodoDeleted, not on-todo-deleted)
    3. Be descriptive: onUserProfileUpdated not onUpdate when the context isn't obvious
    4. Include context in payloads: pass an object ({ todoId, todoText }) rather than loose arguments—it's self-documenting and extensible
    5. Mark truly optional callbacks as optional: true and call them with ?.()

    Complex Communication Pattern

    Let's create a more sophisticated example with a UserManager parent and multiple child components:

    UserManager Parent (user_manager.js):

    import { Component, useState } from "@odoo/owl";
    import { UserCard } from "../user_card/user_card";
    import { UserForm } from "../user_form/user_form";
    import { UserFilters } from "../user_filters/user_filters";
    
    export class UserManager extends Component {
      static template = "my_module.UserManager";
      static components = { UserCard, UserForm, UserFilters };
    
      setup() {
        this.state = useState({
          users: [
            { id: 1, name: "Alice Johnson", role: "admin", active: true, department: "IT" },
            { id: 2, name: "Bob Smith", role: "user", active: true, department: "Sales" },
            { id: 3, name: "Carol Brown", role: "user", active: false, department: "HR" }
          ],
          filters: {
            role: "all",
            department: "all",
            active: true
          },
          editingUser: null,
          showForm: false,
          searchText: ""
        });
      }
    
      // Callbacks for UserCard
      onUserEdit({ user }) {
        console.log("UserManager: Edit user requested", user);
        this.state.editingUser = { ...user }; // Clone for editing
        this.state.showForm = true;
      }
    
      onUserDelete({ userId, userName }) {
        console.log("UserManager: Delete user requested", userId);
    
        if (confirm(`Delete user ${userName}?`)) {
          this.state.users = this.state.users.filter(u => u.id !== userId);
        }
      }
    
      onUserToggleStatus({ userId }) {
        console.log("UserManager: Toggle user status", userId);
    
        const user = this.state.users.find(u => u.id === userId);
        if (user) {
          user.active = !user.active;
        }
      }
    
      // Callbacks for UserForm
      onUserSave({ user }) {
        console.log("UserManager: Save user", user);
    
        if (user.id) {
          // Update existing user
          const index = this.state.users.findIndex(u => u.id === user.id);
          if (index !== -1) {
            this.state.users[index] = user;
          }
        } else {
          // Create new user
          user.id = Math.max(...this.state.users.map(u => u.id), 0) + 1;
          this.state.users.push(user);
        }
    
        this.onFormCancel();
      }
    
      onFormCancel() {
        console.log("UserManager: Form cancelled");
        this.state.showForm = false;
        this.state.editingUser = null;
      }
    

    Three different children (UserCard, UserForm, UserFilters) each get their own dedicated callback methods above—UserManager never needs to guess which child called it, since every callback is passed explicitly to a single component in the template.

      // Callbacks for UserFilters
      onFiltersChanged({ filters }) {
        console.log("UserManager: Filters changed", filters);
        this.state.filters = { ...this.state.filters, ...filters };
      }
    
      onSearchChanged({ searchText }) {
        console.log("UserManager: Search changed", searchText);
        this.state.searchText = searchText;
      }
    
      // Local actions
      openNewUserForm() {
        this.state.editingUser = {
          id: null,
          name: "",
          role: "user",
          active: true,
          department: ""
        };
        this.state.showForm = true;
      }
    

    Finally, filteredUsers is a plain computed getter—no props or callbacks involved—that combines the active filters and search text into the list actually shown in the template:

      // Computed properties
      get filteredUsers() {
        let filtered = this.state.users;
    
        // Apply role filter
        if (this.state.filters.role !== "all") {
          filtered = filtered.filter(u => u.role === this.state.filters.role);
        }
    
        // Apply department filter
        if (this.state.filters.department !== "all") {
          filtered = filtered.filter(u => u.department === this.state.filters.department);
        }
    
        // Apply active filter
        if (this.state.filters.active !== null) {
          filtered = filtered.filter(u => u.active === this.state.filters.active);
        }
    
        // Apply search
        if (this.state.searchText) {
          const search = this.state.searchText.toLowerCase();
          filtered = filtered.filter(u =>
            u.name.toLowerCase().includes(search) ||
            u.department.toLowerCase().includes(search)
          );
        }
    
        return filtered;
      }
    }
    

    And in the template, each child gets exactly the callbacks it needs:

    <UserFilters
        filters="state.filters"
        onFiltersChanged.bind="onFiltersChanged"
        onSearchChanged.bind="onSearchChanged"
    />
    
    <t t-foreach="filteredUsers" t-as="user" t-key="user.id">
        <UserCard
            user="user"
            onEdit.bind="onUserEdit"
            onDelete.bind="onUserDelete"
            onToggleStatus.bind="onUserToggleStatus"
        />
    </t>
    
    <t t-if="state.showForm">
        <UserForm
            user="state.editingUser"
            onSave.bind="onUserSave"
            onCancel.bind="onFormCancel"
        />
    </t>
    

    This example demonstrates how a single parent can coordinate multiple child components through callbacks, maintaining centralized state management while allowing children to remain focused and reusable.


    Data Props vs Callback Props: Decision Framework

    Both travel down as props, but they serve opposite directions of communication:

    Use Callback Props When:

    • Child needs to notify parent of user actions
    • Child needs to request parent to change data
    • Child encounters an error that parent should handle
    • Child completes an async operation
    static props = {
      onDeleted: { type: Function },       // "Something happened"
      onSaveRequested: { type: Function }, // "Please do something"
      onError: { type: Function },         // "Something went wrong"
    };
    

    Use Data Props When:

    • Parent needs to configure child behavior
    • Parent needs to pass data to child
    • Parent controls child's display state
    static props = {
      user: { type: Object },          // Data prop
      readonly: { type: Boolean },     // Configuration prop
      showDetails: { type: Boolean }   // State prop
    };
    

    What about components that aren't parent and child? Callbacks work through the component tree. For communication between distant components (siblings, or across the whole app), you'll use shared state and the environment bus—both covered in Chapter 15.


    Debugging Callbacks

    Console Logging Pattern

    // In child component
    onAction() {
      console.log("Child: Calling onAction callback", { payload: "data" });
      this.props.onAction({ payload: "data" });
    }
    
    // In parent component
    onChildAction(payload) {
      console.log("Parent: onChildAction received", payload);
      // Handle it...
    }
    

    Common Issues and Solutions

    Issue 1: "Cannot read properties of undefined (reading 'state')"

    <!-- Problem: unbound function loses `this` -->
    <TodoItem onDelete="onTodoDelete"/>
    
    <!-- Solution: use the .bind suffix -->
    <TodoItem onDelete.bind="onTodoDelete"/>
    

    Issue 2: "props.onEdit is not a function"

    // The parent didn't pass an optional callback.
    // Declare it optional and call it safely:
    static props = {
      onEdit: { type: Function, optional: true },
    };
    
    onEditClick() {
      this.props.onEdit?.({ todoId: this.props.todo.id });
    }
    

    Issue 3: Callback runs immediately on render

    <!-- Problem: this CALLS the function during rendering -->
    <TodoItem onDelete="onTodoDelete(todo.id)"/>
    
    <!-- Solution: pass a function, don't call one -->
    <TodoItem onDelete="() => this.onTodoDelete(todo.id)"/>
    

    Performance Considerations

    1. Debounce High-Frequency Callbacks

    // Bad: calls the parent on every keystroke
    onInputChange(event) {
      this.props.onTextChanged({ value: event.target.value });
    }
    
    // Good: debounced callback. Debouncing means delaying a function call until
    // a burst of triggering events (e.g. keystrokes) stops for a given period,
    // so you don't fire one call per keystroke. `debounce` is available in
    // @web/core/utils/timing.
    setup() {
      this.debouncedNotify = debounce((value) => {
        this.props.onTextChanged({ value });
      }, 300);
    }
    
    onInputChange(event) {
      this.debouncedNotify(event.target.value);
    }
    

    2. Minimize Payload Size

    // Bad: large payload with unnecessary data
    this.props.onUserSelected({
      fullUser: this.props.user,   // Entire user object
      allUsers: this.props.users   // Unnecessary data
    });
    
    // Good: minimal payload
    this.props.onUserSelected({
      userId: this.props.user.id   // Just the ID
    });
    

    3. Use Stable Function References

    The .bind suffix creates the bound function once, so children don't see a "new" prop on every render. Prefer it over defining fresh arrow functions in templates when the callback doesn't need loop variables.


    Common Pitfalls

    Pitfall 1: Forgetting .bind

    <!-- `this` inside onTodoDelete will be undefined -->
    <TodoItem onDelete="onTodoDelete"/>
    

    Always use .bind, an inline arrow function, or a manual .bind(this) in setup()—see "The .bind Suffix" above.

    Pitfall 2: Calling the Callback Instead of Passing It

    <!-- Problem: this CALLS onTodoDelete during rendering, not on click -->
    <TodoItem onDelete="onTodoDelete(todo.id)"/>
    
    <!-- Solution -->
    <TodoItem onDelete="() => this.onTodoDelete(todo.id)"/>
    

    Pitfall 3: Treating Optional Callbacks as Always Present

    If a callback prop is optional: true, calling it directly (this.props.onEdit(...)) throws when the parent didn't pass it. Use optional chaining: this.props.onEdit?.(...).

    Pitfall 4: Sending Bulky Payloads

    Passing the entire component's state, or unrelated data, in a callback payload couples the child to internals it shouldn't know about. Send the minimum the parent needs (an id, a small object) instead of whole objects or arrays.


    Exercises

    Exercise 1: Add a Callback

    Extend the TodoItem/TodoList example with a new callback prop onPriorityChange that the child calls when the user clicks a "bump priority" button. The parent should update the todo's priority in its state.

    Exercise 2: Fix the Bug

    Given <TodoItem onDelete="onTodoDelete"/> (no .bind, no arrow function), predict what happens when the delete button is clicked, then verify it in the browser console. Fix it using the .bind suffix.

    Exercise 3: Debounced Search

    Build a SearchBox component with a text input and an onSearch callback prop. Debounce the callback so it only fires 300ms after the user stops typing, using debounce from @web/core/utils/timing.


    Callback props are the key to building maintainable OWL applications where components can communicate effectively while remaining loosely coupled. By mastering this pattern—data down, callbacks up—you create applications that are both powerful and predictable.

    TL;DR: Children talk to parents by calling a function the parent passed down as a prop (always with .bind), never by mutating parent state or reaching for a legacy event bus.

    Try it yourself: This chapter's example is available as an installable Odoo 19 addon: simplifyit_owl_book_ch9_ex1. Installation instructions are in the repository README.

    What's Next?

    With data down and callbacks up, the parent-child communication loop is complete. In the next chapter, we'll explore component lifecycle hooks that let you tap into key moments in a component's life to perform initialization, cleanup, and optimization.

    in OWL Book
    Chapter 10: The Component Lifecycle Hooks
    Reading the whole book? Take it with you — free, no sign-up.
    PDF EPUB All chapters
    Useful Links
    • Home
    • About us
    • Blog
    • The OWL 2.0 book
    • Help
    • Contact us
    About us

    Simplify It S.R.L. is an Odoo implementation and development firm based in La Paz, Bolivia, working with companies across Latin America and North America. We have been building on Odoo since version 6.1: custom modules, version migrations and Bolivian localization.

    We are also the authors of the OWL 2.0 book, published free chapter by chapter on our blog.

    Connect with us
    • Contact us
    • [email protected]
    • +591 65144144
    • La Paz, Bolivia
    Follow us
    Copyright © Simplify It S.R.L.
    English (US) | Español (BO)
    Powered by Odoo - Create a free website