¿Por qué este capítulo? Heredé más de un addon donde un widget hijo se metía directamente al estado de su padre para "arreglar" algo, y cada uno de esos se volvió frágil en cuanto alguien cambiaba el padre. Las callback props son la alternativa disciplinada. ¿Qué trata de resolver Odoo con esto? OWL eliminó a propósito el viejo sistema de
trigger/bus de eventos de OWL 1 — Odoo necesitaba comunicación hijo-a-padre rastreable desde el propio template del padre, no dispersa en listeners de eventos que tienes que ir a buscar. Aplicación en la vida real: UnTodoItemdiciéndole a suTodoList"bórrame" o una fila de tabla diciéndole a su padre "me hicieron clic" tienen la misma forma que un widget de campo de formulario diciéndole a la vista de formulario "mi valor cambió" — el patrón que usarás constantemente en cuanto empieces a construir UI real de Odoo.
En el Capítulo 8, establecimos la regla de oro de las props: los datos fluyen en una dirección, del padre hacia el hijo. Un componente hijo nunca debe modificar sus propias props directamente. Esto crea aplicaciones predecibles y mantenibles donde siempre sabes dónde se originan los cambios de datos.
Pero esto plantea una pregunta importante: ¿qué sucede cuando un componente hijo necesita comunicarse de vuelta con su padre? ¿Qué pasa si un usuario hace clic en un botón "Eliminar" dentro de un componente TodoItem? El hijo no puede eliminar los datos por sí mismo porque esos datos pertenecen al padre y fueron pasados hacia abajo como prop.
Aquí es donde entra la segunda mitad del ciclo de comunicación: las callback props (props de función).
Mientras los datos fluyen hacia abajo a través de props, la comunicación fluye hacia arriba a través de llamadas a funciones. El padre le pasa una función al hijo como prop; cuando algo sucede dentro del hijo (un clic, un envío, un error), el hijo simplemente llama a esa función. El padre decide qué hacer con la información. Esto completa el ciclo de flujo de datos mientras mantiene al padre firmemente en control del estado de la aplicación.
¿Vienes de OWL 1.x? El framework antiguo tenía un mecanismo
trigger()que despachaba eventos personalizados hacia arriba en el árbol de componentes, y los padres los escuchaban cont-on-nombre-eventoen la etiqueta del componente. Ese mecanismo fue eliminado en OWL 2.0. En OWL 2.0,t-on-funciona solo en elementos DOM reales (para eventos nativos comoclickeinput), y la comunicación hijo-a-padre se hace exclusivamente con callback props. Si vesthis.trigger("algun-evento")en código antiguo, eso es OWL 1 legacy (cubriremos su migración en el Capítulo 17).
Entendiendo el Flujo de Comunicación
Visualicemos cómo funciona esta comunicación bidireccional:
Componente Padre
|-- Estado: { todos: [...], users: [...] }
|-- Los datos fluyen HACIA ABAJO vía props ↓
|-- Los callbacks fluyen HACIA ABAJO vía props ↓
|
Componente Hijo
|-- Recibe: props de datos (solo lectura) + callback props
|-- Ocurre una interacción del usuario
|-- El hijo LLAMA al callback ↑
|
Componente Padre
|-- El callback se ejecuta en el padre
|-- Actualiza su propio estado
|-- El nuevo estado fluye HACIA ABAJO como props ↓
Este patrón asegura que: - Los padres controlan los datos y la lógica de negocio - Los hijos permanecen puros y reutilizables - El flujo de datos es predecible y fácil de depurar - Los componentes están débilmente acoplados y son mantenibles
Patrón Básico de Callbacks
Empecemos con un ejemplo simple. Crearemos un componente TodoItem que puede notificar a su padre cuando necesita ser eliminado.
El Componente Hijo: TodoItem
JavaScript de TodoItem (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: funciones que el padre nos da para "llamar a casa"
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
};
// Manejador de evento para el botón eliminar
onDeleteClick() {
console.log("TodoItem: Clic en eliminar, llamando al callback onDelete");
// Llamar a la función del padre con un objeto de payload
this.props.onDelete({
todoId: this.props.todo.id,
todoText: this.props.todo.text
});
}
// Manejador de evento para alternar completado
onToggleClick() {
console.log("TodoItem: Clic en toggle, llamando al callback onToggle");
this.props.onToggle({
todoId: this.props.todo.id,
currentStatus: this.props.todo.completed
});
}
// Manejador de evento para solicitar edición
onEditClick() {
console.log("TodoItem: Clic en editar, llamando al callback onEdit");
// Los callbacks opcionales pueden ser undefined — usa optional chaining
this.props.onEdit?.({
todoId: this.props.todo.id,
currentText: this.props.todo.text
});
}
// Propiedades computadas para estilos
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';
}
}
Plantilla de TodoItem (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">
<!-- Checkbox para Alternar Completado -->
<div class="todo-toggle me-3">
<input type="checkbox"
class="form-check-input"
t-att-checked="props.todo.completed"
t-on-click="onToggleClick"/>
</div>
<!-- Contenido del Todo -->
<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>
<!-- Insignia de Prioridad -->
<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>
<!-- Fecha Límite -->
<t t-if="props.todo.dueDate">
<small class="text-muted">
Vence: <t t-esc="props.todo.dueDate"/>
</small>
</t>
</div>
</div>
<!-- Botones de Acción -->
<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>
Fíjate en que t-on-click se usa en elementos DOM reales (el checkbox y los botones)—eso es exactamente para lo que sirve t-on- en OWL 2.0: escuchar eventos nativos del navegador. El puente hacia el padre es la callback prop.
El Componente Padre: TodoList
Ahora creemos el componente padre que proporciona esos callbacks:
JavaScript de TodoList (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: "Aprender lo básico de OWL",
completed: true,
priority: "medium",
dueDate: "2024-03-20"
},
{
id: 2,
text: "Construir una app de todos",
completed: false,
priority: "high",
dueDate: "2024-03-25"
},
{
id: 3,
text: "Dominar la comunicación entre componentes",
completed: false,
priority: "low",
dueDate: "2024-03-30"
}
],
editingTodoId: null,
newTodoText: "",
showCompleted: true,
showPriority: true
});
}
// Callback: el hijo nos pidió eliminar un todo
onTodoDelete({ todoId, todoText }) {
console.log(`TodoList: onTodoDelete llamado para ID ${todoId}`);
// Mostrar confirmación (opcional)
if (confirm(`¿Estás seguro de que quieres eliminar "${todoText}"?`)) {
// Actualizar el estado del padre - esto causará un re-render
this.state.todos = this.state.todos.filter(todo => todo.id !== todoId);
console.log(`TodoList: Todo ${todoId} eliminado exitosamente`);
}
}
// Callback: el hijo nos pidió alternar el completado
onTodoToggle({ todoId }) {
console.log(`TodoList: onTodoToggle llamado para ID ${todoId}`);
// Encontrar y actualizar el todo
const todo = this.state.todos.find(t => t.id === todoId);
if (todo) {
todo.completed = !todo.completed;
console.log(`TodoList: Estado de completado del todo ${todoId} cambiado a ${todo.completed}`);
}
}
// Callback: el hijo nos pidió empezar a editar un todo
onTodoEdit({ todoId, currentText }) {
console.log(`TodoList: onTodoEdit llamado para ID ${todoId}`);
// Entrar en modo de edición
this.state.editingTodoId = todoId;
this.state.newTodoText = currentText;
}
// Métodos locales para gestionar 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;
}
// Propiedades computadas
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;
}
}
Plantilla de TodoList (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">
<!-- Encabezado -->
<div class="todo-header mb-4">
<div class="d-flex justify-content-between align-items-center">
<div>
<h2>Mi Lista de Todos</h2>
<p class="text-muted mb-0">
<t t-esc="completedCount"/> completados de <t t-esc="totalCount"/> todos
</p>
</div>
<!-- Controles de Vista -->
<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 ? 'Ocultar Completados' : 'Mostrar Todos'"/>
</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>
Prioridad
</button>
</div>
</div>
</div>
<!-- Formulario para Añadir Nuevo Todo -->
<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="Añadir un nuevo 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>
Añadir Todo
</button>
</div>
</div>
</div>
<!-- Formulario de Edición -->
<t t-if="state.editingTodoId">
<div class="edit-todo-form mb-4 p-3 bg-warning bg-opacity-10 rounded">
<h5>Editar 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>
Guardar
</button>
<button class="btn btn-secondary" t-on-click="cancelEdit">
<i class="fa fa-times"></i>
Cancelar
</button>
</div>
</div>
</div>
</t>
<!-- Elementos Todo -->
<div class="todo-items">
<t t-if="visibleTodos.length > 0">
<t t-foreach="visibleTodos" t-as="todo" t-key="todo.id">
<!-- Aquí es donde le entregamos nuestros callbacks al hijo -->
<TodoItem
todo="todo"
canEdit="true"
canDelete="true"
showPriority="state.showPriority"
onDelete.bind="onTodoDelete"
onToggle.bind="onTodoToggle"
onEdit.bind="onTodoEdit"
/>
</t>
</t>
<!-- Estado Vacío -->
<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 hay todos que mostrar</h4>
<p class="text-muted">
<t t-if="!state.showCompleted">
¡Todos los todos están completados! Activa "Mostrar Todos" para verlos.
</t>
<t t-else="">
Añade tu primer todo arriba para empezar.
</t>
</p>
</div>
</t>
</div>
</div>
</t>
</templates>
El Sufijo .bind: Detalle Pequeño, Gran Diferencia
Mira de cerca cómo se pasan los callbacks:
<TodoItem onDelete.bind="onTodoDelete"/>
El sufijo .bind le dice a OWL que vincule (bind) la función al componente padre antes de pasarla hacia abajo. Sin él, cuando el hijo llame a this.props.onDelete(...), el this dentro de onTodoDelete sería undefined y this.state fallaría.
Tienes tres opciones equivalentes—elige una y sé consistente:
<!-- Opción 1 (recomendada): el sufijo .bind -->
<TodoItem onDelete.bind="onTodoDelete"/>
<!-- Opción 2: una función flecha inline (vincula `this` automáticamente) -->
<TodoItem onDelete="(payload) => this.onTodoDelete(payload)"/>
// Opción 3: vincular manualmente en setup()
setup() {
this.onTodoDelete = this.onTodoDelete.bind(this);
}
El sufijo .bind es la forma idiomática de OWL 2.0 y lo que verás en todo el código de Odoo.
Patrones Avanzados de Callbacks
1. Eventos Nativos y Detener la Propagación
t-on- en elementos DOM soporta modificadores útiles:
<!-- .stop llama a event.stopPropagation() por ti -->
<button t-on-click.stop="onDeleteClick">Eliminar</button>
<!-- .prevent llama a event.preventDefault() -->
<form t-on-submit.prevent="onSubmit">...</form>
<!-- .enter se dispara solo con la tecla Enter -->
<input t-on-keyup.enter="addNewTodo"/>
Usa estos para controlar el evento DOM nativo; luego llama a tu callback prop como de costumbre.
2. Invocación Condicional de Callbacks
// Solo llamar al callback si se cumplen ciertas condiciones
onDeleteClick() {
if (this.props.todo.completed) {
this.props.onDelete({ todoId: this.props.todo.id });
} else {
this.props.onConfirmDelete?.({
todoId: this.props.todo.id,
message: "Este todo no está completado. ¿Estás seguro?"
});
}
}
3. Callbacks Asíncronos
Los callbacks pueden ser async, lo que permite al hijo esperar el trabajo del padre (por ejemplo, para mostrar un spinner mientras el padre guarda):
// Hijo
async onSaveClick() {
this.state.saving = true;
try {
await this.props.onSave({ data: this.state.formData });
} finally {
this.state.saving = false;
}
}
4. Validación en el Padre
// El padre valida el payload antes de procesarlo
onTodoUpdate({ todoId, newText }) {
// Validar los datos
if (!todoId || typeof newText !== 'string' || newText.trim().length === 0) {
console.error("Actualización de todo inválida:", { todoId, newText });
return;
}
// Proceder con la actualización
const todo = this.state.todos.find(t => t.id === todoId);
if (todo) {
todo.text = newText.trim();
}
}
Convenciones de Nombres para Callbacks
Seguir convenciones de nombres consistentes hace tu código más mantenible:
// En la definición de props del hijo: "on" + lo que pasó, en camelCase
static props = {
onTodoCreated: { type: Function },
onUserSelected: { type: Function },
onFormSubmitted: { type: Function },
onError: { type: Function, optional: true },
};
Mejores Prácticas:
- Prefijo
on:onDelete,onSave,onUserSelected—se lee instantáneamente como "callback" - Usa camelCase: las callback props son props normales, así que siguen la convención de nombres de props (
onTodoDeleted, noon-todo-deleted) - Sé descriptivo:
onUserProfileUpdateden vez deonUpdatecuando el contexto no sea obvio - Incluye contexto en los payloads: pasa un objeto (
{ todoId, todoText }) en vez de argumentos sueltos—es auto-documentado y extensible - Marca los callbacks realmente opcionales con
optional: truey llámalos con?.()
Patrón de Comunicación Complejo
Creemos un ejemplo más sofisticado con un padre UserManager y múltiples componentes hijos:
Padre UserManager (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: "Ventas" },
{ id: 3, name: "Carol Brown", role: "user", active: false, department: "RRHH" }
],
filters: {
role: "all",
department: "all",
active: true
},
editingUser: null,
showForm: false,
searchText: ""
});
}
// Callbacks para UserCard
onUserEdit({ user }) {
console.log("UserManager: Edición de usuario solicitada", user);
this.state.editingUser = { ...user }; // Clonar para editar
this.state.showForm = true;
}
onUserDelete({ userId, userName }) {
console.log("UserManager: Eliminación de usuario solicitada", userId);
if (confirm(`¿Eliminar usuario ${userName}?`)) {
this.state.users = this.state.users.filter(u => u.id !== userId);
}
}
onUserToggleStatus({ userId }) {
console.log("UserManager: Alternar estado del usuario", userId);
const user = this.state.users.find(u => u.id === userId);
if (user) {
user.active = !user.active;
}
}
// Callbacks para UserForm
onUserSave({ user }) {
console.log("UserManager: Guardar usuario", user);
if (user.id) {
// Actualizar usuario existente
const index = this.state.users.findIndex(u => u.id === user.id);
if (index !== -1) {
this.state.users[index] = user;
}
} else {
// Crear nuevo usuario
user.id = Math.max(...this.state.users.map(u => u.id), 0) + 1;
this.state.users.push(user);
}
this.onFormCancel();
}
onFormCancel() {
console.log("UserManager: Formulario cancelado");
this.state.showForm = false;
this.state.editingUser = null;
}
Tres hijos distintos (UserCard, UserForm, UserFilters) tienen arriba sus propios métodos de callback dedicados — UserManager nunca necesita adivinar qué hijo lo llamó, ya que cada callback se pasa explícitamente a un único componente en la plantilla.
// Callbacks para UserFilters
onFiltersChanged({ filters }) {
console.log("UserManager: Filtros cambiados", filters);
this.state.filters = { ...this.state.filters, ...filters };
}
onSearchChanged({ searchText }) {
console.log("UserManager: Búsqueda cambiada", searchText);
this.state.searchText = searchText;
}
// Acciones locales
openNewUserForm() {
this.state.editingUser = {
id: null,
name: "",
role: "user",
active: true,
department: ""
};
this.state.showForm = true;
}
Finalmente, filteredUsers es un simple getter computado — sin props ni callbacks involucrados — que combina los filtros activos y el texto de búsqueda en la lista que realmente se muestra en la plantilla:
// Propiedades computadas
get filteredUsers() {
let filtered = this.state.users;
// Aplicar filtro de rol
if (this.state.filters.role !== "all") {
filtered = filtered.filter(u => u.role === this.state.filters.role);
}
// Aplicar filtro de departamento
if (this.state.filters.department !== "all") {
filtered = filtered.filter(u => u.department === this.state.filters.department);
}
// Aplicar filtro de activos
if (this.state.filters.active !== null) {
filtered = filtered.filter(u => u.active === this.state.filters.active);
}
// Aplicar búsqueda
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;
}
}
Y en la plantilla, cada hijo recibe exactamente los callbacks que necesita:
<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>
Este ejemplo demuestra cómo un solo padre puede coordinar múltiples componentes hijos a través de callbacks, manteniendo la gestión de estado centralizada mientras permite que los hijos permanezcan enfocados y reutilizables.
Props de Datos vs Callback Props: Marco de Decisión
Ambas viajan hacia abajo como props, pero sirven a direcciones opuestas de comunicación:
Usa Callback Props Cuando:
- El hijo necesita notificar al padre de acciones del usuario
- El hijo necesita solicitar al padre que cambie datos
- El hijo encuentra un error que el padre debería manejar
- El hijo completa una operación asíncrona
static props = {
onDeleted: { type: Function }, // "Algo pasó"
onSaveRequested: { type: Function }, // "Por favor haz algo"
onError: { type: Function }, // "Algo salió mal"
};
Usa Props de Datos Cuando:
- El padre necesita configurar el comportamiento del hijo
- El padre necesita pasar datos al hijo
- El padre controla el estado de visualización del hijo
static props = {
user: { type: Object }, // Prop de datos
readonly: { type: Boolean }, // Prop de configuración
showDetails: { type: Boolean } // Prop de estado
};
¿Y los componentes que no son padre e hijo? Los callbacks funcionan a través del árbol de componentes. Para comunicación entre componentes distantes (hermanos, o a través de toda la app), usarás estado compartido y el bus del entorno—ambos cubiertos en el Capítulo 15.
Depurando Callbacks
Patrón de Console Logging
// En el componente hijo
onAction() {
console.log("Hijo: Llamando al callback onAction", { payload: "data" });
this.props.onAction({ payload: "data" });
}
// En el componente padre
onChildAction(payload) {
console.log("Padre: onChildAction recibió", payload);
// Manejarlo...
}
Problemas Comunes y Soluciones
Problema 1: "Cannot read properties of undefined (reading 'state')"
<!-- Problema: la función sin vincular pierde `this` -->
<TodoItem onDelete="onTodoDelete"/>
<!-- Solución: usa el sufijo .bind -->
<TodoItem onDelete.bind="onTodoDelete"/>
Problema 2: "props.onEdit is not a function"
// El padre no pasó un callback opcional.
// Decláralo opcional y llámalo con seguridad:
static props = {
onEdit: { type: Function, optional: true },
};
onEditClick() {
this.props.onEdit?.({ todoId: this.props.todo.id });
}
Problema 3: El callback se ejecuta inmediatamente al renderizar
<!-- Problema: esto LLAMA a la función durante el renderizado -->
<TodoItem onDelete="onTodoDelete(todo.id)"/>
<!-- Solución: pasa una función, no la llames -->
<TodoItem onDelete="() => this.onTodoDelete(todo.id)"/>
Consideraciones de Rendimiento
1. Aplica Debounce a Callbacks de Alta Frecuencia
// Malo: llama al padre en cada pulsación de tecla
onInputChange(event) {
this.props.onTextChanged({ value: event.target.value });
}
// Bueno: callback con debounce. "Debounce" significa retrasar una llamada
// hasta que una ráfaga de eventos disparadores (ej. pulsaciones de tecla)
// se detenga durante un período dado, para no hacer una llamada por cada
// tecla. `debounce` está disponible en @web/core/utils/timing.
setup() {
this.debouncedNotify = debounce((value) => {
this.props.onTextChanged({ value });
}, 300);
}
onInputChange(event) {
this.debouncedNotify(event.target.value);
}
2. Minimiza el Tamaño del Payload
// Malo: payload grande con datos innecesarios
this.props.onUserSelected({
fullUser: this.props.user, // El objeto de usuario completo
allUsers: this.props.users // Datos innecesarios
});
// Bueno: payload mínimo
this.props.onUserSelected({
userId: this.props.user.id // Solo el ID
});
3. Usa Referencias de Función Estables
El sufijo .bind crea la función vinculada una sola vez, así que los hijos no ven una prop "nueva" en cada render. Prefiérelo sobre definir funciones flecha nuevas en las plantillas cuando el callback no necesita variables del bucle.
Errores Comunes
Error 1: Olvidar .bind
<!-- `this` dentro de onTodoDelete será undefined -->
<TodoItem onDelete="onTodoDelete"/>
Usa siempre .bind, una función flecha en línea, o un .bind(this) manual en setup() — ver "El Sufijo .bind" más arriba.
Error 2: Llamar al Callback en Vez de Pasarlo
<!-- Problema: esto LLAMA a onTodoDelete durante el renderizado, no al hacer clic -->
<TodoItem onDelete="onTodoDelete(todo.id)"/>
<!-- Solución -->
<TodoItem onDelete="() => this.onTodoDelete(todo.id)"/>
Error 3: Tratar los Callbacks Opcionales Como Siempre Presentes
Si una callback prop es optional: true, llamarla directamente (this.props.onEdit(...)) lanza un error cuando el padre no la pasó. Usa encadenamiento opcional: this.props.onEdit?.(...).
Error 4: Enviar Payloads Voluminosos
Pasar todo el estado del componente, o datos no relacionados, en el payload de un callback acopla al hijo con detalles internos que no debería conocer. Envía lo mínimo que el padre necesita (un id, un objeto pequeño) en vez de objetos o arrays completos.
Ejercicios
Ejercicio 1: Añade un Callback
Extiende el ejemplo TodoItem/TodoList con una nueva callback prop onPriorityChange que el hijo llame cuando el usuario haga clic en un botón "subir prioridad". El padre debe actualizar la prioridad del todo en su estado.
Ejercicio 2: Corrige el Bug
Dado <TodoItem onDelete="onTodoDelete"/> (sin .bind, sin función flecha), predice qué pasa al hacer clic en el botón de eliminar, y luego verifícalo en la consola del navegador. Corrígelo usando el sufijo .bind.
Ejercicio 3: Búsqueda con Debounce
Construye un componente SearchBox con un input de texto y una callback prop onSearch. Aplica debounce al callback para que solo se dispare 300ms después de que el usuario deja de escribir, usando debounce de @web/core/utils/timing.
Las callback props son la clave para construir aplicaciones OWL mantenibles donde los componentes pueden comunicarse efectivamente mientras permanecen débilmente acoplados. Al dominar este patrón—datos hacia abajo, callbacks hacia arriba—creas aplicaciones que son tanto poderosas como predecibles.
TL;DR: Los hijos le hablan a los padres llamando a una función que el padre pasó hacia abajo como prop (siempre con .bind), nunca mutando el estado del padre ni recurriendo a un bus de eventos legado.
Pruébalo tú mismo: El ejemplo de este capítulo está disponible como addon instalable de Odoo 19: simplifyit_owl_book_ch9_ex1. Las instrucciones de instalación están en el README del repositorio.
¿Qué Sigue?
Con datos hacia abajo y callbacks hacia arriba, el ciclo de comunicación padre-hijo está completo. En el siguiente capítulo, exploraremos los hooks del ciclo de vida del componente que te permiten aprovechar momentos clave en la vida de un componente para realizar inicialización, limpieza y optimización.