¿Por qué este capítulo? Tarde o temprano, dos componentes que no tienen nada que ver entre sí en el árbol necesitan reaccionar al mismo cambio — un badge de notificaciones y una lista sin relación que ambos deben reflejar un registro nuevo. Las props solas no pueden cruzar el árbol, y recurrir a un objeto global mutable o a eventos del DOM es la forma de terminar con bugs que nadie puede rastrear. ¿Qué trata de resolver Odoo con esto? Coordinar estado de UI compartido sin abandonar el modelo de reactividad de OWL — un store debería sentirse como
useState, solo que visible desde más de un lugar, registrado de la misma forma que cualquier otro servicio. Aplicación en la vida real: Un flag de "cambios sin guardar" que muestran varios widgets en la misma vista de formulario, o un estado compartido de borrador/carrito en una pantalla personalizada de varios pasos donde los pasos no tienen una relación directa padre-hijo.
Hemos dominado los fundamentos del flujo de datos en OWL: las props fluyen hacia abajo de padre a hijo, y los eventos burbujean hacia arriba de hijo a padre. Este patrón funciona hermosamente para componentes que están directamente relacionados—un formulario padre y sus campos de entrada, una lista y sus elementos, una tarjeta y sus botones.
Pero, ¿qué sucede cuando los componentes están muy separados en el árbol de componentes, o cuando múltiples componentes no relacionados necesitan compartir los mismos datos?
Imagina que tienes un componente UserMenu en el encabezado principal que muestra el nombre y avatar del usuario actual. En algún otro lugar de tu aplicación, tienes una página ProfileSettings donde los usuarios pueden actualizar su información. Cuando un usuario cambia su nombre en la página ProfileSettings, ¿cómo se entera el UserMenu en el encabezado—posiblemente renderizado por una parte completamente diferente de la aplicación?
Pasar props hacia abajo a través de docenas de componentes intermedios (un antipatrón doloroso conocido como "prop drilling") es ineficiente y crea una pesadilla de mantenimiento. Emitir un evento que tiene que burbujear por todo el árbol de la aplicación es igualmente desordenado y frágil.
La solución es un store de estado global—una fuente única y centralizada de verdad para datos que necesitan ser compartidos a través de toda tu aplicación. Cualquier componente puede acceder a estos datos o disparar cambios en ellos, y cualquier componente que use esos datos se actualizará automáticamente cuando cambien.
Entendiendo el Problema: Cuando el Estado Local No Es Suficiente
Antes de sumergirnos en la gestión de estado global, entendamos exactamente cuándo y por qué la necesitas.
El Problema del Prop Drilling
Considera esta jerarquía de componentes en una aplicación Odoo:
App
|-- Header
| |-- Navigation
| |-- UserMenu (necesita datos del usuario)
|-- Main
| |-- Sidebar
| | |-- QuickActions (necesita permisos del usuario)
| |-- Content
| |-- Dashboard (necesita preferencias del usuario)
| |-- ProfilePage
| |-- ProfileForm (actualiza datos del usuario)
|-- Footer
|-- UserInfo (necesita datos del usuario)
Sin gestión de estado global, compartir datos del usuario requeriría:
- Almacenar datos del usuario en el nivel superior (componente App)
- Pasarlos hacia abajo a través de Header → UserMenu
- Pasarlos hacia abajo a través de Main → Sidebar → QuickActions
- Pasarlos hacia abajo a través de Main → Content → Dashboard
- Pasar funciones de actualización hacia arriba desde ProfileForm a través de Content → Main → App
- Pasar datos hacia abajo a Footer → UserInfo
Esto crea una red de props que no tienen nada que ver con los componentes intermedios—son solo mensajeros de datos. Es frágil, verboso y hace que refactorizar sea difícil.
El Problema de la Cadena de Callbacks
Podrías pensar, "¡Solo usaré callback props!" Pero a través de largas distancias este enfoque tiene sus propios problemas:
// En ProfileForm, profundo en el árbol de componentes
this.props.onUserUpdated(newUserData);
// Cada componente en la cadena necesita recibir el callback y pasarlo hacia abajo
// App -> Main -> Content -> ProfilePage -> ProfileForm...
// y los datos actualizados aún tienen que viajar de vuelta hacia UserMenu, QuickActions, etc.
Esto crea acoplamiento fuerte entre componentes que no deberían conocerse entre sí, y es fácil romper la cadena.
Cuándo Necesitas Estado Global
Deberías considerar gestión de estado global cuando:
- Preocupaciones transversales: Autenticación de usuario, configuraciones de tema, preferencias de idioma
- Datos compartidos: Información del usuario actual, contenidos del carrito de compras, conteo de notificaciones
- Caché de datos remotos: Respuestas de API que múltiples componentes necesitan
- Configuraciones de aplicación: Feature flags, configuración, permisos
- Actualizaciones en tiempo real: Datos de WebSocket que afectan múltiples componentes
{width=100%}
Entendiendo los Stores en Odoo
En la arquitectura de Odoo, un store es un servicio especializado que gestiona estado reactivo. A diferencia de servicios regulares que proporcionan funcionalidad, los stores están específicamente diseñados para mantener datos a los que los componentes pueden suscribirse.
La Anatomía de un Store
Un store en Odoo típicamente tiene estas características:
- Estado Reactivo: Usa la función
reactivede OWL para hacer que los cambios de datos disparen actualizaciones de componentes - Lógica Centralizada: Todas las operaciones que modifican el estado están contenidas dentro del store
- Integración de Servicios: Registrado como un servicio de Odoo, haciéndolo disponible en toda la aplicación
- Sistema de Eventos: Puede emitir eventos cuando ocurren cambios significativos
Stores Integrados en Odoo
Antes de crear stores personalizados, es importante saber qué proporciona Odoo ya:
Servicio User: Información sobre el usuario actual
const user = useService("user");
console.log(user.name, user.isAdmin, user.partnerId);
Servicio Company: Información de la compañía actual
const company = useService("company");
console.log(company.currentCompany.name, company.allowedCompanies);
Servicio Notification: Para mostrar mensajes
const notification = useService("notification");
notification.add("¡Guardado exitoso!", { type: "success" });
Ahora vamos a crear nuestro propio store para entender el patrón completamente.
Creando Tu Primer Store: Gestión de Temas
Construyamos un store integral de temas que gestiona modo oscuro/claro, preferencias de tamaño de fuente, y personalización de colores a través de toda una aplicación Odoo.
1. Construyendo el Servicio Theme Store
Archivo: mi_modulo/static/src/services/theme_store.js
/** @odoo-module **/
import { reactive } from "@odoo/owl";
import { registry } from "@web/core/registry";
import { browser } from "@web/core/browser/browser";
export class ThemeStore {
constructor() {
// Cargar preferencias guardadas desde localStorage
const preferenciasGuardadas = this.cargarPreferencias();
// Crear estado reactivo
this.state = reactive({
modo: preferenciasGuardadas.modo || "claro",
tamañoFuente: preferenciasGuardadas.tamañoFuente || "mediano",
colorPrimario: preferenciasGuardadas.colorPrimario || "#007bff",
radioBorde: preferenciasGuardadas.radioBorde || "mediano",
animaciones: preferenciasGuardadas.animaciones !== false, // por defecto true
});
// Aplicar tema inmediatamente cuando se crea el store
this.aplicarTema();
}
/**
* Cargar preferencias desde localStorage
*/
cargarPreferencias() {
try {
const guardado = browser.localStorage.getItem('odoo_preferencias_tema');
return guardado ? JSON.parse(guardado) : {};
} catch (error) {
console.warn("Falló la carga de preferencias de tema:", error);
return {};
}
}
/**
* Guardar preferencias actuales en localStorage
*/
guardarPreferencias() {
try {
const preferencias = {
modo: this.state.modo,
tamañoFuente: this.state.tamañoFuente,
colorPrimario: this.state.colorPrimario,
radioBorde: this.state.radioBorde,
animaciones: this.state.animaciones,
};
browser.localStorage.setItem('odoo_preferencias_tema', JSON.stringify(preferencias));
} catch (error) {
console.warn("Falló el guardado de preferencias de tema:", error);
}
}
/**
* Alternar entre modo claro y oscuro
*/
alternarModo() {
this.state.modo = this.state.modo === "claro" ? "oscuro" : "claro";
this.aplicarTema();
this.guardarPreferencias();
}
/**
* Establecer un modo de tema específico
*/
establecerModo(modo) {
if (["claro", "oscuro", "auto"].includes(modo)) {
this.state.modo = modo;
this.aplicarTema();
this.guardarPreferencias();
}
}
/**
* Establecer tamaño de fuente
*/
establecerTamañoFuente(tamaño) {
if (["pequeño", "mediano", "grande", "xl"].includes(tamaño)) {
this.state.tamañoFuente = tamaño;
this.aplicarTema();
this.guardarPreferencias();
}
}
/**
* Establecer color primario
*/
establecerColorPrimario(color) {
// Validar formato de color (hex)
if (/^#[0-9A-F]{6}$/i.test(color)) {
this.state.colorPrimario = color;
this.aplicarTema();
this.guardarPreferencias();
}
}
/**
* Establecer preferencia de radio de borde
*/
establecerRadioBorde(radio) {
if (["ninguno", "pequeño", "mediano", "grande"].includes(radio)) {
this.state.radioBorde = radio;
this.aplicarTema();
this.guardarPreferencias();
}
}
/**
* Alternar animaciones encendido/apagado
*/
alternarAnimaciones() {
this.state.animaciones = !this.state.animaciones;
this.aplicarTema();
this.guardarPreferencias();
}
/**
* Restablecer al tema por defecto
*/
restablecerADefecto() {
this.state.modo = "claro";
this.state.tamañoFuente = "mediano";
this.state.colorPrimario = "#007bff";
this.state.radioBorde = "mediano";
this.state.animaciones = true;
this.aplicarTema();
this.guardarPreferencias();
}
/**
* Aplicar tema actual al documento
*/
aplicarTema() {
const root = document.documentElement;
// Aplicar propiedades personalizadas de CSS
root.style.setProperty('--color-primario', this.state.colorPrimario);
// Mapeo de tamaños de fuente
const tamañosFuente = {
pequeño: '14px',
mediano: '16px',
grande: '18px',
xl: '20px'
};
root.style.setProperty('--tamaño-fuente-base', tamañosFuente[this.state.tamañoFuente]);
// Mapeo de radios de borde
const radiosBorde = {
ninguno: '0',
pequeño: '4px',
mediano: '8px',
grande: '16px'
};
root.style.setProperty('--radio-borde', radiosBorde[this.state.radioBorde]);
// Aplicar clase de modo de tema
root.className = root.className.replace(/tema-\w+/g, '');
root.classList.add(`tema-${this.state.modo}`);
// Manejar animaciones
if (!this.state.animaciones) {
root.classList.add('sin-animaciones');
} else {
root.classList.remove('sin-animaciones');
}
// Modo auto: detectar preferencia del sistema
if (this.state.modo === 'auto') {
const prefiereOscuro = window.matchMedia('(prefers-color-scheme: dark)').matches;
root.classList.add(`tema-${prefiereOscuro ? 'oscuro' : 'claro'}`);
}
}
/**
* Obtener valores computados del tema
*/
get temaComputado() {
return {
esOscuro: this.state.modo === 'oscuro' ||
(this.state.modo === 'auto' && window.matchMedia('(prefers-color-scheme: dark)').matches),
esClaro: this.state.modo === 'claro' ||
(this.state.modo === 'auto' && !window.matchMedia('(prefers-color-scheme: dark)').matches),
...this.state
};
}
}
// Registrar como servicio de Odoo
export const themeStoreService = {
dependencies: [],
start(env) {
const store = new ThemeStore();
// Escuchar cambios de tema del sistema cuando esté en modo auto
window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', () => {
if (store.state.modo === 'auto') {
store.aplicarTema();
}
});
return store;
},
};
registry.category("services").add("themeStore", themeStoreService);
2. Accediendo al Store con useService
Ahora cualquier componente puede acceder a este theme store:
Componente: theme_display.js
/** @odoo-module **/
import { Component, xml } from "@odoo/owl";
import { useService } from "@web/core/utils/hooks";
export class ThemeDisplay extends Component {
static template = xml`
<div class="visualizador-tema">
<h3>Tema Actual</h3>
<div class="info-tema">
<p><strong>Modo:</strong> <span t-esc="themeStore.state.modo"/></p>
<p><strong>Tamaño de Fuente:</strong> <span t-esc="themeStore.state.tamañoFuente"/></p>
<p><strong>Color Primario:</strong>
<span t-esc="themeStore.state.colorPrimario"/>
<div class="vista-previa-color" t-att-style="\`background-color: \${themeStore.state.colorPrimario}\`"/>
</p>
<p><strong>Radio de Borde:</strong> <span t-esc="themeStore.state.radioBorde"/></p>
<p><strong>Animaciones:</strong> <span t-esc="themeStore.state.animaciones ? 'Habilitadas' : 'Deshabilitadas'"/></p>
</div>
</div>
`;
setup() {
this.themeStore = useService("themeStore");
}
}
Componente: theme_controls.js
/** @odoo-module **/
import { Component, xml } from "@odoo/owl";
import { useService } from "@web/core/utils/hooks";
export class ThemeControls extends Component {
static template = xml`
<div class="controles-tema">
<h3>Configuración de Tema</h3>
<!-- Selección de Modo -->
<div class="grupo-control">
<label>Modo de Tema:</label>
<select t-model="themeStore.state.modo" t-on-change="alCambiarModo">
<option value="claro">Claro</option>
<option value="oscuro">Oscuro</option>
<option value="auto">Auto (Sistema)</option>
</select>
</div>
<!-- Tamaño de Fuente -->
<div class="grupo-control">
<label>Tamaño de Fuente:</label>
<select t-model="themeStore.state.tamañoFuente" t-on-change="alCambiarTamañoFuente">
<option value="pequeño">Pequeño</option>
<option value="mediano">Mediano</option>
<option value="grande">Grande</option>
<option value="xl">Extra Grande</option>
</select>
</div>
<!-- Color Primario -->
<div class="grupo-control">
<label>Color Primario:</label>
<input type="color"
t-att-value="themeStore.state.colorPrimario"
t-on-change="alCambiarColor"/>
</div>
<!-- Radio de Borde -->
<div class="grupo-control">
<label>Radio de Borde:</label>
<select t-model="themeStore.state.radioBorde" t-on-change="alCambiarRadio">
<option value="ninguno">Ninguno</option>
<option value="pequeño">Pequeño</option>
<option value="mediano">Mediano</option>
<option value="grande">Grande</option>
</select>
</div>
<!-- Alternar Animaciones -->
<div class="grupo-control">
<label>
<input type="checkbox"
t-att-checked="themeStore.state.animaciones"
t-on-change="alAlternarAnimacion"/>
Habilitar Animaciones
</label>
</div>
<!-- Acciones Rápidas -->
<div class="acciones-rapidas">
<button class="btn btn-secondary" t-on-click="themeStore.alternarModo">
Alternar Modo Rápido
</button>
<button class="btn btn-outline-secondary" t-on-click="themeStore.restablecerADefecto">
Restablecer a Defecto
</button>
</div>
</div>
`;
setup() {
this.themeStore = useService("themeStore");
}
alCambiarModo(event) {
this.themeStore.establecerModo(event.target.value);
}
alCambiarTamañoFuente(event) {
this.themeStore.establecerTamañoFuente(event.target.value);
}
alCambiarColor(event) {
this.themeStore.establecerColorPrimario(event.target.value);
}
alCambiarRadio(event) {
this.themeStore.establecerRadioBorde(event.target.value);
}
alAlternarAnimacion(event) {
this.themeStore.alternarAnimaciones();
}
}
3. Integración CSS
Agrega CSS que responda a las variables de tu tema:
Archivo: mi_modulo/static/src/css/tema.css
/* Propiedades Personalizadas de CSS que el store controla */
:root {
--color-primario: #007bff;
--tamaño-fuente-base: 16px;
--radio-borde: 8px;
}
/* Tipografía base */
body {
font-size: var(--tamaño-fuente-base);
transition: font-size 0.3s ease;
}
/* Estilos de modo de tema */
.tema-claro {
--color-fondo: #ffffff;
--color-texto: #333333;
--color-borde: #dee2e6;
}
.tema-oscuro {
--color-fondo: #1a1a1a;
--color-texto: #ffffff;
--color-borde: #444444;
}
body {
background-color: var(--color-fondo);
color: var(--color-texto);
border-color: var(--color-borde);
}
/* Estilos de componentes que usan variables de tema */
.btn-primary {
background-color: var(--color-primario);
border-radius: var(--radio-borde);
}
.card {
border-radius: var(--radio-borde);
border-color: var(--color-borde);
}
/* Deshabilitar animaciones cuando se solicite */
.sin-animaciones * {
animation-duration: 0s !important;
transition-duration: 0s !important;
}
/* Estilo de controles de tema */
.controles-tema .grupo-control {
margin-bottom: 1rem;
}
.controles-tema label {
display: block;
margin-bottom: 0.5rem;
font-weight: 500;
}
.vista-previa-color {
width: 20px;
height: 20px;
border-radius: var(--radio-borde);
display: inline-block;
margin-left: 8px;
border: 1px solid var(--color-borde);
}
Patrones Avanzados de Store
1. Store con Operaciones Asíncronas
Muchos stores necesitan interactuar con APIs. Aquí hay un patrón para un store de carrito de compras:
export class ShoppingCartStore {
constructor(orm, notification) {
this.orm = orm;
this.notification = notification;
this.state = reactive({
elementos: [],
cargando: false,
total: 0,
});
this.cargarCarrito();
}
async cargarCarrito() {
this.state.cargando = true;
try {
const datosCarrito = await this.orm.call("sale.order", "get_current_cart", []);
this.state.elementos = datosCarrito.elementos;
this.state.total = datosCarrito.total;
} catch (error) {
this.notification.add("Falló la carga del carrito", { type: "danger" });
} finally {
this.state.cargando = false;
}
}
async agregarElemento(idProducto, cantidad = 1) {
this.state.cargando = true;
try {
await this.orm.call("sale.order", "add_to_cart", [idProducto, cantidad]);
await this.cargarCarrito(); // Refrescar datos del carrito
this.notification.add("Elemento agregado al carrito", { type: "success" });
} catch (error) {
this.notification.add("Falló agregar elemento", { type: "danger" });
} finally {
this.state.cargando = false;
}
}
async removerElemento(idElemento) {
this.state.cargando = true;
try {
await this.orm.call("sale.order", "remove_from_cart", [idElemento]);
await this.cargarCarrito();
this.notification.add("Elemento removido", { type: "info" });
} catch (error) {
this.notification.add("Falló remover elemento", { type: "danger" });
} finally {
this.state.cargando = false;
}
}
get conteoElementos() {
return this.state.elementos.reduce((total, elemento) => total + elemento.cantidad, 0);
}
get estaVacio() {
return this.state.elementos.length === 0;
}
}
export const shoppingCartService = {
dependencies: ["orm", "notification"],
start(env, { orm, notification }) {
return new ShoppingCartStore(orm, notification);
},
};
2. Store con Propiedades Computadas
Para estado derivado complejo, usa getters:
export class NotificationStore {
constructor() {
this.state = reactive({
notificaciones: [],
configuraciones: {
email: true,
push: true,
sms: false,
}
});
}
get conteoNoLeidas() {
return this.state.notificaciones.filter(n => !n.leida).length;
}
get notificacionesUrgentes() {
return this.state.notificaciones.filter(n => n.prioridad === 'urgente' && !n.leida);
}
get tieneNotificacionesUrgentes() {
return this.notificacionesUrgentes.length > 0;
}
get notificacionesPorTipo() {
return this.state.notificaciones.reduce((acc, notificacion) => {
if (!acc[notificacion.tipo]) {
acc[notificacion.tipo] = [];
}
acc[notificacion.tipo].push(notificacion);
return acc;
}, {});
}
}
3. Store con Emisión de Eventos
Para aplicaciones complejas, los stores pueden emitir eventos:
import { EventBus } from "@odoo/owl";
export class UserActivityStore extends EventBus {
constructor() {
super();
this.state = reactive({
estaEnLinea: navigator.onLine,
ultimaActividad: Date.now(),
tiempoInactivo: 0,
});
this.configurarRastreoActividad();
}
configurarRastreoActividad() {
// Rastrear estado online/offline
window.addEventListener('online', () => {
this.state.estaEnLinea = true;
this.trigger('conectividad-cambio', { enLinea: true });
});
window.addEventListener('offline', () => {
this.state.estaEnLinea = false;
this.trigger('conectividad-cambio', { enLinea: false });
});
// Rastrear actividad del usuario
const reiniciarActividad = () => {
this.state.ultimaActividad = Date.now();
this.state.tiempoInactivo = 0;
};
['mousedown', 'mousemove', 'keypress', 'scroll', 'touchstart'].forEach(evento => {
document.addEventListener(evento, reiniciarActividad, true);
});
// Verificar usuarios inactivos cada minuto
setInterval(() => {
this.state.tiempoInactivo = Date.now() - this.state.ultimaActividad;
if (this.state.tiempoInactivo > 5 * 60 * 1000) { // 5 minutos
this.trigger('usuario-inactivo', { tiempoInactivo: this.state.tiempoInactivo });
}
}, 60000);
}
}
Mejores Prácticas para Gestión de Store
1. Mantén los Stores Enfocados
Cada store debe tener una sola responsabilidad clara:
// Bueno: Stores enfocados
- UserStore: Información del usuario actual y autenticación
- ThemeStore: Preferencias de UI y tematización
- CartStore: Estado y operaciones del carrito de compras
- NotificationStore: Notificaciones dentro de la app
// Malo: Store objeto dios
- AppStore: Todo mezclado junto
2. Usa Getters para Valores Computados
No almacenes valores derivados en estado—calcúlalos con getters:
// Bueno: Propiedad computada
get precioTotal() {
return this.state.elementos.reduce((suma, elemento) => suma + elemento.precio * elemento.cantidad, 0);
}
// Malo: Valor derivado almacenado (puede desincronizarse)
this.state.precioTotal = /* valor computado */;
3. Haz los Cambios de Estado Explícitos
Siempre proporciona métodos para modificar estado, no permitas mutaciones directas:
// Bueno: Mutaciones controladas
agregarNotificacion(mensaje, tipo = 'info') {
this.state.notificaciones.push({
id: Date.now(),
mensaje,
tipo,
marcaTiempo: new Date(),
leida: false
});
}
// Malo: Mutación directa de estado desde componentes
// notificationStore.state.notificaciones.push(...);
4. Maneja Estados de Carga
Para operaciones asíncronas, siempre proporciona indicadores de carga:
async cargarDatosUsuario() {
this.state.cargando = true;
this.state.error = null;
try {
const datosUsuario = await this.orm.call("res.users", "get_current_user", []);
this.state.usuario = datosUsuario;
} catch (error) {
this.state.error = error.message;
} finally {
this.state.cargando = false;
}
}
5. Proporciona APIs Claras
Documenta la interfaz pública de tu store:
/**
* Theme Store
*
* Estado:
* - modo: 'claro' | 'oscuro' | 'auto'
* - tamañoFuente: 'pequeño' | 'mediano' | 'grande' | 'xl'
* - colorPrimario: string de color hex
*
* Métodos:
* - alternarModo(): Alternar entre claro/oscuro
* - establecerModo(modo): Establecer modo específico
* - establecerTamañoFuente(tamaño): Establecer tamaño de fuente
* - establecerColorPrimario(color): Establecer color primario
* - restablecerADefecto(): Restablecer todas las configuraciones
*
* Computado:
* - temaComputado: Objeto con valores de tema resueltos
*/
Cuándo NO Usar Estado Global
El estado global es poderoso, pero no siempre es la solución correcta:
Usa Estado Local Cuando:
- Los datos solo son usados por un componente y sus hijos
- Los datos no necesitan persistir entre montajes/desmontajes de componentes
- La relación entre componentes es clara y directa
Usa Props/Eventos Cuando:
- Los componentes tienen una relación padre-hijo clara
- El flujo de datos es simple y unidireccional
- Quieres mantener componentes débilmente acoplados
Usa Estado Global Cuando:
- Múltiples componentes no relacionados necesitan los mismos datos
- Los datos necesitan persistir a través de la navegación
- Estás lidiando con preferencias de usuario o configuraciones de aplicación
- Necesitas coordinar estado a través de diferentes partes de la app
Debuggeando Problemas de Store
1. Agrega Logging a Métodos de Store
establecerModo(modo) {
console.log(`Modo de tema cambiando de ${this.state.modo} a ${modo}`);
this.state.modo = modo;
this.aplicarTema();
this.guardarPreferencias();
}
2. Usa Browser DevTools
Los stores son objetos JavaScript regulares, así que puedes inspeccionarlos en la consola:
// En consola del navegador
const themeStore = odoo.__SERVICES__.themeStore;
console.log(themeStore.state);
3. Agrega Validación
establecerTamañoFuente(tamaño) {
const tamañosValidos = ["pequeño", "mediano", "grande", "xl"];
if (!tamañosValidos.includes(tamaño)) {
console.error(`Tamaño de fuente inválido: ${tamaño}. Opciones válidas: ${tamañosValidos.join(', ')}`);
return;
}
this.state.tamañoFuente = tamaño;
this.aplicarTema();
this.guardarPreferencias();
}
Ejemplo de Integración del Mundo Real
Aquí hay un ejemplo completo mostrando cómo múltiples servicios trabajan juntos en un escenario de producción:
export class DashboardStore {
constructor(orm, notification, user) {
this.orm = orm;
this.notification = notification;
this.user = user;
this.state = reactive({
// Datos del dashboard
metricas: {
ventasTotales: 0,
clientesActivos: 0,
pedidosPendientes: 0,
ingresosMensuales: 0
},
// Estados de carga
cargandoMetricas: false,
cargandoGraficos: false,
// Configuración del dashboard
layoutConfigurado: this.user.dashboardLayout || 'grid',
widgetsVisibles: this.user.visibleWidgets || ['ventas', 'clientes', 'pedidos'],
// Datos de gráficos
datosVentas: [],
datosClientes: [],
// Filtros
rangoFecha: 'ultimo-mes',
equipoSeleccionado: null,
// Errores
errores: {}
});
// Cargar datos iniciales
this.inicializar();
}
async inicializar() {
try {
await Promise.all([
this.cargarMetricas(),
this.cargarDatosGraficos()
]);
this.notification.add("Dashboard cargado exitosamente", {
type: "success"
});
} catch (error) {
this.notification.add("Error al cargar dashboard", {
type: "danger",
sticky: true
});
console.error("Error de inicialización de dashboard:", error);
}
}
El constructor arma la forma reactiva de state y de inmediato dispara inicializar(), que carga todo lo que necesita el dashboard en paralelo y muestra una notificación en ambos casos. A continuación vienen los dos métodos de carga que inicializar() invoca — cada uno sigue el mismo patrón de bandera cargando... y try/catch/finally que ya viste antes en este capítulo:
async cargarMetricas() {
this.state.cargandoMetricas = true;
this.state.errores.metricas = null;
try {
const metricas = await this.orm.call(
"dashboard.analytics",
"get_metrics",
[],
{
fecha_desde: this.obtenerFechaDesde(),
fecha_hasta: this.obtenerFechaHasta(),
equipo_id: this.state.equipoSeleccionado
}
);
this.state.metricas = {
ventasTotales: metricas.total_sales,
clientesActivos: metricas.active_customers,
pedidosPendientes: metricas.pending_orders,
ingresosMensuales: metricas.monthly_revenue
};
} catch (error) {
this.state.errores.metricas = error.message;
console.error("Error al cargar métricas:", error);
} finally {
this.state.cargandoMetricas = false;
}
}
async cargarDatosGraficos() {
this.state.cargandoGraficos = true;
this.state.errores.graficos = null;
try {
const [datosVentas, datosClientes] = await Promise.all([
this.orm.call("dashboard.analytics", "get_sales_chart_data", [], {
periodo: this.state.rangoFecha
}),
this.orm.call("dashboard.analytics", "get_customer_chart_data", [], {
periodo: this.state.rangoFecha
})
]);
this.state.datosVentas = datosVentas;
this.state.datosClientes = datosClientes;
} catch (error) {
this.state.errores.graficos = error.message;
console.error("Error al cargar datos de gráficos:", error);
} finally {
this.state.cargandoGraficos = false;
}
}
Con la carga resuelta, el resto del store es la API pública que realmente usan otros componentes: métodos pequeños que cambian una sola parte del estado y, cuando hace falta, disparan una recarga o un guardado. Fíjate que ninguno de estos métodos accede a this.state desde afuera del store — cada mutación pasa por un método con nombre, que es justamente lo que mantiene depurable un store que va creciendo:
// Métodos para cambiar configuración
cambiarRangoFecha(nuevoRango) {
if (this.state.rangoFecha !== nuevoRango) {
this.state.rangoFecha = nuevoRango;
this.actualizarDatos();
}
}
seleccionarEquipo(equipoId) {
if (this.state.equipoSeleccionado !== equipoId) {
this.state.equipoSeleccionado = equipoId;
this.actualizarDatos();
}
}
alternarWidget(widgetId) {
const widgets = [...this.state.widgetsVisibles];
const indice = widgets.indexOf(widgetId);
if (indice > -1) {
widgets.splice(indice, 1);
} else {
widgets.push(widgetId);
}
this.state.widgetsVisibles = widgets;
this.guardarConfiguracionUsuario();
}
cambiarLayout(nuevoLayout) {
this.state.layoutConfigurado = nuevoLayout;
this.guardarConfiguracionUsuario();
}
async actualizarDatos() {
await Promise.all([
this.cargarMetricas(),
this.cargarDatosGraficos()
]);
}
async guardarConfiguracionUsuario() {
try {
await this.orm.call("res.users", "save_dashboard_config", [], {
layout: this.state.layoutConfigurado,
visible_widgets: this.state.widgetsVisibles
});
this.notification.add("Configuración guardada", { type: "success" });
} catch (error) {
this.notification.add("Error al guardar configuración", { type: "warning" });
}
}
Finalmente, un par de métodos auxiliares simples y tres getters computados exponen datos derivados (¿algo está cargando? ¿hay errores? ¿cuál es la configuración completa actual?) sin duplicarlos en state, seguidos del registro del servicio que convierte esta clase en un singleton — una única instancia compartida, creada una vez y reutilizada en todas partes — a la que cada componente accede vía useService("dashboardStore"):
obtenerFechaDesde() {
const ahora = new Date();
switch (this.state.rangoFecha) {
case 'ultima-semana':
return new Date(ahora.getTime() - 7 * 24 * 60 * 60 * 1000);
case 'ultimo-mes':
return new Date(ahora.getFullYear(), ahora.getMonth() - 1, ahora.getDate());
case 'ultimo-trimestre':
return new Date(ahora.getFullYear(), ahora.getMonth() - 3, ahora.getDate());
case 'ultimo-año':
return new Date(ahora.getFullYear() - 1, ahora.getMonth(), ahora.getDate());
default:
return new Date(ahora.getFullYear(), ahora.getMonth() - 1, ahora.getDate());
}
}
obtenerFechaHasta() {
return new Date();
}
// Getters computados
get tieneErrores() {
return Object.keys(this.state.errores).some(key => this.state.errores[key]);
}
get estaCargando() {
return this.state.cargandoMetricas || this.state.cargandoGraficos;
}
get configuracionCompleta() {
return {
layout: this.state.layoutConfigurado,
widgets: this.state.widgetsVisibles,
filtros: {
rangoFecha: this.state.rangoFecha,
equipo: this.state.equipoSeleccionado
}
};
}
}
// Servicio del Dashboard Store
export const dashboardStoreService = {
dependencies: ["orm", "notification", "user"],
start(env, { orm, notification, user }) {
return new DashboardStore(orm, notification, user);
},
};
registry.category("services").add("dashboardStore", dashboardStoreService);
Errores Comunes
1. Olvidar suscribirse con useState
Crear el store con reactive({...}) solo hace reactivos los datos en la fuente. Un componente que lee el store directamente (useService("dashboardStore").state.metricas) sin envolverlo en useState no se va a re-renderizar cuando esos datos cambien:
// MAL: lee el estado reactivo una vez, el componente nunca se re-renderiza
this.store = useService("dashboardStore");
// BIEN: suscribe este componente a la reactividad del store
this.store = useState(useService("dashboardStore").state);
2. Mutar el store desde cualquier lugar
Si cualquier componente puede hacer this.store.state.equipoSeleccionado = 5 directamente, pierdes el rastro de quién cambia qué y por qué. Mantén cada mutación detrás de un método con nombre en el store (seleccionarEquipo(id)), incluso cuando el método sea de una sola línea — te da un único lugar donde agregar logging, validación o un efecto secundario más adelante.
3. Una instancia de store por componente
Registrar el store como servicio (como se mostró arriba) garantiza una única instancia compartida. Instanciar new DashboardStore(...) directamente dentro del setup() de un componente crea una copia privada que ningún otro componente puede ver — anulando todo el propósito del estado global.
4. Recurrir al estado global demasiado pronto
No todo necesita vivir en un store. Si solo un componente (y sus hijos directos, vía props) lee un dato, un useState local es más simple y fácil de razonar. Promueve el estado a un store solo cuando dos o más componentes sin relación directa realmente necesitan compartirlo.
La gestión de estado global con stores es esencial para construir aplicaciones Odoo complejas e interconectadas. Al centralizar el estado compartido y proporcionar APIs claras para acceder y modificarlo, creas aplicaciones que son más mantenibles, más predecibles y más fáciles de debuggear.
La clave es saber cuándo usar estado global versus estado local, y diseñar tus stores con responsabilidades claras e interfaces limpias. Domina este patrón, y podrás construir aplicaciones sofisticadas que escalen graciosamente a medida que crecen en complejidad.
En el próximo capítulo, exploraremos cómo asegurar que tus componentes funcionen correctamente a través de pruebas y técnicas efectivas de debugging.
TL;DR: Registra un store reactive() como servicio y consúmelo con useState(useService(...).state) donde haga falta, así componentes sin relación pueden compartir estado reactivo sin prop drilling.
Pruébalo tú mismo: El ejemplo de este capítulo está disponible como addon instalable de Odoo 19: simplifyit_owl_book_ch15_ex1. Las instrucciones de instalación están en el README del repositorio.
Ejercicios
- Store de contador compartido. Crea un servicio
counterStoreusandoreactive({ count: 0 })con métodosincrement()ydecrement(). Consúmelo desde dos componentes hermanos conuseState(useService("counterStore").state)y confirma que al hacer clic en un botón de uno se actualiza el contador mostrado en el otro. - Agrega un getter computado. Extiende el store del Ejercicio 1 con un getter
get isPositive(), y úsalo en un template para mostrar condicionalmente una advertencia cuando el contador se vuelva negativo. - Local vs. global. Toma un componente que hayas construido en los ejercicios de un capítulo anterior (o el
DataTabledel Capítulo 14) y decide: ¿alguno de sus estados realmente necesita ser global? Escribe una oración justificando tu respuesta antes de escribir código.
¿Qué Sigue?
Que el estado sea correcto no es lo mismo que el estado en el que puedas confiar frente a un cliente — el Capítulo 16 Parte 1 cubre cómo probar y depurar componentes OWL para detectar regresiones antes de que ellos lo hagan.