Why this chapter? Sooner or later, two components that have nothing to do with each other in the tree need to react to the same change — a notification badge and an unrelated list both needing to reflect a new record. Props alone can't reach across the tree, and reaching for a global mutable object or DOM events is how you get bugs nobody can trace. What is Odoo trying to solve with this? Coordinating shared UI state without abandoning OWL's reactivity model — a store should feel like
useState, just visible from more than one place, registered the same way any other service is. Real-world application: A shared "unsaved changes" flag surfaced by several widgets on the same form view, or shared draft/cart state across a custom multi-step screen where the steps aren't in a direct parent-child relationship.
We've mastered the fundamentals of data flow in OWL: props flow down from parent to child, and events bubble up from child to parent. This pattern works beautifully for components that are directly related—a parent form and its input fields, a list and its items, a card and its buttons.
But what happens when components are far apart in the component tree, or when multiple unrelated components need to share the same data?
Imagine you have a UserMenu component in the main header that displays the current user's name and avatar. Somewhere else in your application, you have a ProfileSettings page where users can update their information. When a user changes their name on the ProfileSettings page, how does the UserMenu in the header—possibly rendered by a completely different part of the application—find out about it?
Passing props down through dozens of intermediate components (a painful anti-pattern known as "prop drilling") is inefficient and creates a maintenance nightmare. Emitting an event that has to bubble up through the entire application tree is equally messy and fragile.
The solution is a global state store—a centralized, single source of truth for data that needs to be shared across your entire application. Any component can access this data or trigger changes to it, and any component using that data will automatically update when it changes.
Understanding the Problem: When Local State Isn't Enough
Before diving into global state management, let's understand exactly when and why you need it.
The Prop Drilling Problem
Consider this component hierarchy in an Odoo application:
App
|-- Header
| |-- Navigation
| |-- UserMenu (needs user data)
|-- Main
| |-- Sidebar
| | |-- QuickActions (needs user permissions)
| |-- Content
| |-- Dashboard (needs user preferences)
| |-- ProfilePage
| |-- ProfileForm (updates user data)
|-- Footer
|-- UserInfo (needs user data)
Without global state management, sharing user data would require:
- Storing user data at the top level (App component)
- Passing it down through Header → UserMenu
- Passing it down through Main → Sidebar → QuickActions
- Passing it down through Main → Content → Dashboard
- Passing update functions up from ProfileForm through Content → Main → App
- Passing data down to Footer → UserInfo
This creates a web of props that have nothing to do with the intermediate components—they're just data couriers. It's fragile, verbose, and makes refactoring difficult.
The Callback Chain Problem
You might think, "I'll just use callback props!" But over long distances this approach has its own issues:
// In ProfileForm, deep in the component tree
this.props.onUserUpdated(newUserData);
// Every component in the chain needs to receive the callback and pass it down
// App -> Main -> Content -> ProfilePage -> ProfileForm...
// and the updated data still has to travel back down to UserMenu, QuickActions, etc.
This creates tight coupling between components that shouldn't know about each other, and it's easy to break the chain.
When You Need Global State
You should consider global state management when:
- Cross-cutting concerns: User authentication, theme settings, language preferences
- Shared data: Current user info, shopping cart contents, notification count
- Remote data caching: API responses that multiple components need
- Application-wide settings: Feature flags, configuration, permissions
- Real-time updates: WebSocket data that affects multiple components
{width=100%}
Understanding Stores in Odoo
In Odoo's architecture, a store is a specialized service that manages reactive state. Unlike regular services that provide functionality, stores are specifically designed to hold data that components can subscribe to.
The Anatomy of a Store
A store in Odoo typically has these characteristics:
- Reactive State: Uses OWL's
reactivefunction to make data changes trigger component updates - Centralized Logic: All operations that modify the state are contained within the store
- Service Integration: Registered as an Odoo service, making it available throughout the application
- Event System: Can emit events when significant changes occur
Built-in Stores in Odoo
Before creating custom stores, it's important to know what Odoo already provides:
User Service: Information about the current user
const user = useService("user");
console.log(user.name, user.isAdmin, user.partnerId);
Company Service: Current company information
const company = useService("company");
console.log(company.currentCompany.name, company.allowedCompanies);
Notification Service: For displaying messages
const notification = useService("notification");
notification.add("Save successful!", { type: "success" });
Let's now create our own store to understand the pattern completely.
Creating Your First Store: Theme Management
Let's build a comprehensive theme store that manages dark/light mode, font size preferences, and color customization across an entire Odoo application.
1. Building the Theme Store Service
File: my_module/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() {
// Load saved preferences from localStorage
const savedPreferences = this.loadPreferences();
// Create reactive state
this.state = reactive({
mode: savedPreferences.mode || "light",
fontSize: savedPreferences.fontSize || "medium",
primaryColor: savedPreferences.primaryColor || "#007bff",
borderRadius: savedPreferences.borderRadius || "medium",
animations: savedPreferences.animations !== false, // default true
});
// Apply theme immediately when store is created
this.applyTheme();
}
/**
* Load preferences from localStorage
*/
loadPreferences() {
try {
const saved = browser.localStorage.getItem('odoo_theme_preferences');
return saved ? JSON.parse(saved) : {};
} catch (error) {
console.warn("Failed to load theme preferences:", error);
return {};
}
}
/**
* Save current preferences to localStorage
*/
savePreferences() {
try {
const preferences = {
mode: this.state.mode,
fontSize: this.state.fontSize,
primaryColor: this.state.primaryColor,
borderRadius: this.state.borderRadius,
animations: this.state.animations,
};
browser.localStorage.setItem('odoo_theme_preferences', JSON.stringify(preferences));
} catch (error) {
console.warn("Failed to save theme preferences:", error);
}
}
/**
* Toggle between light and dark mode
*/
toggleMode() {
this.state.mode = this.state.mode === "light" ? "dark" : "light";
this.applyTheme();
this.savePreferences();
}
/**
* Set a specific theme mode
*/
setMode(mode) {
if (["light", "dark", "auto"].includes(mode)) {
this.state.mode = mode;
this.applyTheme();
this.savePreferences();
}
}
/**
* Set font size
*/
setFontSize(size) {
if (["small", "medium", "large", "xl"].includes(size)) {
this.state.fontSize = size;
this.applyTheme();
this.savePreferences();
}
}
/**
* Set primary color
*/
setPrimaryColor(color) {
// Validate color format (hex)
if (/^#[0-9A-F]{6}$/i.test(color)) {
this.state.primaryColor = color;
this.applyTheme();
this.savePreferences();
}
}
/**
* Set border radius preference
*/
setBorderRadius(radius) {
if (["none", "small", "medium", "large"].includes(radius)) {
this.state.borderRadius = radius;
this.applyTheme();
this.savePreferences();
}
}
/**
* Toggle animations on/off
*/
toggleAnimations() {
this.state.animations = !this.state.animations;
this.applyTheme();
this.savePreferences();
}
/**
* Reset to default theme
*/
resetToDefaults() {
this.state.mode = "light";
this.state.fontSize = "medium";
this.state.primaryColor = "#007bff";
this.state.borderRadius = "medium";
this.state.animations = true;
this.applyTheme();
this.savePreferences();
}
/**
* Apply current theme to document
*/
applyTheme() {
const root = document.documentElement;
// Apply CSS custom properties
root.style.setProperty('--primary-color', this.state.primaryColor);
// Font size mapping
const fontSizes = {
small: '14px',
medium: '16px',
large: '18px',
xl: '20px'
};
root.style.setProperty('--base-font-size', fontSizes[this.state.fontSize]);
// Border radius mapping
const borderRadiuses = {
none: '0',
small: '4px',
medium: '8px',
large: '16px'
};
root.style.setProperty('--border-radius', borderRadiuses[this.state.borderRadius]);
// Apply theme mode class
root.className = root.className.replace(/theme-\w+/g, '');
root.classList.add(`theme-${this.state.mode}`);
// Handle animations
if (!this.state.animations) {
root.classList.add('no-animations');
} else {
root.classList.remove('no-animations');
}
// Auto mode: detect system preference
if (this.state.mode === 'auto') {
const prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
root.classList.add(`theme-${prefersDark ? 'dark' : 'light'}`);
}
}
/**
* Get computed theme values
*/
get computedTheme() {
return {
isDark: this.state.mode === 'dark' ||
(this.state.mode === 'auto' && window.matchMedia('(prefers-color-scheme: dark)').matches),
isLight: this.state.mode === 'light' ||
(this.state.mode === 'auto' && !window.matchMedia('(prefers-color-scheme: dark)').matches),
...this.state
};
}
}
// Register as an Odoo service
export const themeStoreService = {
dependencies: [],
start(env) {
const store = new ThemeStore();
// Listen for system theme changes when in auto mode
window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', () => {
if (store.state.mode === 'auto') {
store.applyTheme();
}
});
return store;
},
};
registry.category("services").add("themeStore", themeStoreService);
2. Accessing the Store with useService
Now any component can access this theme store:
Component: theme_display.js
/** @odoo-module **/
import { Component } from "@odoo/owl";
import { useService } from "@web/core/utils/hooks";
export class ThemeDisplay extends Component {
static template = xml`
<div class="theme-display">
<h3>Current Theme</h3>
<div class="theme-info">
<p><strong>Mode:</strong> <span t-esc="themeStore.state.mode"/></p>
<p><strong>Font Size:</strong> <span t-esc="themeStore.state.fontSize"/></p>
<p><strong>Primary Color:</strong>
<span t-esc="themeStore.state.primaryColor"/>
<div class="color-preview" t-att-style="`background-color: ${themeStore.state.primaryColor}`"/>
</p>
<p><strong>Border Radius:</strong> <span t-esc="themeStore.state.borderRadius"/></p>
<p><strong>Animations:</strong> <span t-esc="themeStore.state.animations ? 'Enabled' : 'Disabled'"/></p>
</div>
</div>
`;
setup() {
this.themeStore = useService("themeStore");
}
}
Component: theme_controls.js
/** @odoo-module **/
import { Component } from "@odoo/owl";
import { useService } from "@web/core/utils/hooks";
export class ThemeControls extends Component {
static template = xml`
<div class="theme-controls">
<h3>Theme Settings</h3>
<!-- Mode Selection -->
<div class="control-group">
<label>Theme Mode:</label>
<select t-model="themeStore.state.mode" t-on-change="onModeChange">
<option value="light">Light</option>
<option value="dark">Dark</option>
<option value="auto">Auto (System)</option>
</select>
</div>
<!-- Font Size -->
<div class="control-group">
<label>Font Size:</label>
<select t-model="themeStore.state.fontSize" t-on-change="onFontSizeChange">
<option value="small">Small</option>
<option value="medium">Medium</option>
<option value="large">Large</option>
<option value="xl">Extra Large</option>
</select>
</div>
<!-- Primary Color -->
<div class="control-group">
<label>Primary Color:</label>
<input type="color"
t-att-value="themeStore.state.primaryColor"
t-on-change="onColorChange"/>
</div>
<!-- Border Radius -->
<div class="control-group">
<label>Border Radius:</label>
<select t-model="themeStore.state.borderRadius" t-on-change="onRadiusChange">
<option value="none">None</option>
<option value="small">Small</option>
<option value="medium">Medium</option>
<option value="large">Large</option>
</select>
</div>
<!-- Animations Toggle -->
<div class="control-group">
<label>
<input type="checkbox"
t-att-checked="themeStore.state.animations"
t-on-change="onAnimationToggle"/>
Enable Animations
</label>
</div>
<!-- Quick Actions -->
<div class="quick-actions">
<button class="btn btn-secondary" t-on-click="themeStore.toggleMode">
Quick Toggle Mode
</button>
<button class="btn btn-outline-secondary" t-on-click="themeStore.resetToDefaults">
Reset to Defaults
</button>
</div>
</div>
`;
setup() {
this.themeStore = useService("themeStore");
}
onModeChange(event) {
this.themeStore.setMode(event.target.value);
}
onFontSizeChange(event) {
this.themeStore.setFontSize(event.target.value);
}
onColorChange(event) {
this.themeStore.setPrimaryColor(event.target.value);
}
onRadiusChange(event) {
this.themeStore.setBorderRadius(event.target.value);
}
onAnimationToggle(event) {
this.themeStore.toggleAnimations();
}
}
3. CSS Integration
Add CSS that responds to your theme variables:
File: my_module/static/src/css/theme.css
/* CSS Custom Properties that the store controls */
:root {
--primary-color: #007bff;
--base-font-size: 16px;
--border-radius: 8px;
}
/* Base typography */
body {
font-size: var(--base-font-size);
transition: font-size 0.3s ease;
}
/* Theme mode styles */
.theme-light {
--bg-color: #ffffff;
--text-color: #333333;
--border-color: #dee2e6;
}
.theme-dark {
--bg-color: #1a1a1a;
--text-color: #ffffff;
--border-color: #444444;
}
body {
background-color: var(--bg-color);
color: var(--text-color);
border-color: var(--border-color);
}
/* Component styles that use theme variables */
.btn-primary {
background-color: var(--primary-color);
border-radius: var(--border-radius);
}
.card {
border-radius: var(--border-radius);
border-color: var(--border-color);
}
/* Disable animations when requested */
.no-animations * {
animation-duration: 0s !important;
transition-duration: 0s !important;
}
/* Theme controls styling */
.theme-controls .control-group {
margin-bottom: 1rem;
}
.theme-controls label {
display: block;
margin-bottom: 0.5rem;
font-weight: 500;
}
.color-preview {
width: 20px;
height: 20px;
border-radius: var(--border-radius);
display: inline-block;
margin-left: 8px;
border: 1px solid var(--border-color);
}
Advanced Store Patterns
1. Store with Async Operations
Many stores need to interact with APIs. Here's a pattern for a shopping cart store:
export class ShoppingCartStore {
constructor(orm, notification) {
this.orm = orm;
this.notification = notification;
this.state = reactive({
items: [],
loading: false,
total: 0,
});
this.loadCart();
}
async loadCart() {
this.state.loading = true;
try {
const cartData = await this.orm.call("sale.order", "get_current_cart", []);
this.state.items = cartData.items;
this.state.total = cartData.total;
} catch (error) {
this.notification.add("Failed to load cart", { type: "danger" });
} finally {
this.state.loading = false;
}
}
async addItem(productId, quantity = 1) {
this.state.loading = true;
try {
await this.orm.call("sale.order", "add_to_cart", [productId, quantity]);
await this.loadCart(); // Refresh cart data
this.notification.add("Item added to cart", { type: "success" });
} catch (error) {
this.notification.add("Failed to add item", { type: "danger" });
} finally {
this.state.loading = false;
}
}
async removeItem(itemId) {
this.state.loading = true;
try {
await this.orm.call("sale.order", "remove_from_cart", [itemId]);
await this.loadCart();
this.notification.add("Item removed", { type: "info" });
} catch (error) {
this.notification.add("Failed to remove item", { type: "danger" });
} finally {
this.state.loading = false;
}
}
get itemCount() {
return this.state.items.reduce((total, item) => total + item.quantity, 0);
}
get isEmpty() {
return this.state.items.length === 0;
}
}
export const shoppingCartService = {
dependencies: ["orm", "notification"],
start(env, { orm, notification }) {
return new ShoppingCartStore(orm, notification);
},
};
2. Store with Computed Properties
For complex derived state, use getters:
export class NotificationStore {
constructor() {
this.state = reactive({
notifications: [],
settings: {
email: true,
push: true,
sms: false,
}
});
}
get unreadCount() {
return this.state.notifications.filter(n => !n.read).length;
}
get urgentNotifications() {
return this.state.notifications.filter(n => n.priority === 'urgent' && !n.read);
}
get hasUrgentNotifications() {
return this.urgentNotifications.length > 0;
}
get notificationsByType() {
return this.state.notifications.reduce((acc, notification) => {
if (!acc[notification.type]) {
acc[notification.type] = [];
}
acc[notification.type].push(notification);
return acc;
}, {});
}
}
3. Store with Event Emitting
For complex applications, stores can emit events:
import { EventBus } from "@odoo/owl";
export class UserActivityStore extends EventBus {
constructor() {
super();
this.state = reactive({
isOnline: navigator.onLine,
lastActivity: Date.now(),
idleTime: 0,
});
this.setupActivityTracking();
}
setupActivityTracking() {
// Track online/offline status
window.addEventListener('online', () => {
this.state.isOnline = true;
this.trigger('connectivity-changed', { online: true });
});
window.addEventListener('offline', () => {
this.state.isOnline = false;
this.trigger('connectivity-changed', { online: false });
});
// Track user activity
const resetActivity = () => {
this.state.lastActivity = Date.now();
this.state.idleTime = 0;
};
['mousedown', 'mousemove', 'keypress', 'scroll', 'touchstart'].forEach(event => {
document.addEventListener(event, resetActivity, true);
});
// Check for idle users every minute
setInterval(() => {
this.state.idleTime = Date.now() - this.state.lastActivity;
if (this.state.idleTime > 5 * 60 * 1000) { // 5 minutes
this.trigger('user-idle', { idleTime: this.state.idleTime });
}
}, 60000);
}
}
Best Practices for Store Management
1. Keep Stores Focused
Each store should have a single, clear responsibility:
// Good: Focused stores
- UserStore: Current user info and authentication
- ThemeStore: UI preferences and theming
- CartStore: Shopping cart state and operations
- NotificationStore: In-app notifications
// Bad: God object store
- AppStore: Everything mixed together
2. Use Getters for Computed Values
Don't store derived values in state—compute them with getters:
// Good: Computed property
get totalPrice() {
return this.state.items.reduce((sum, item) => sum + item.price * item.quantity, 0);
}
// Bad: Stored derived value (can get out of sync)
this.state.totalPrice = /* computed value */;
3. Make State Changes Explicit
Always provide methods for modifying state, don't allow direct mutations:
// Good: Controlled mutations
addNotification(message, type = 'info') {
this.state.notifications.push({
id: Date.now(),
message,
type,
timestamp: new Date(),
read: false
});
}
// Bad: Direct state mutation from components
// notificationStore.state.notifications.push(...);
4. Handle Loading States
For async operations, always provide loading indicators:
async loadUserData() {
this.state.loading = true;
this.state.error = null;
try {
const userData = await this.orm.call("res.users", "get_current_user", []);
this.state.user = userData;
} catch (error) {
this.state.error = error.message;
} finally {
this.state.loading = false;
}
}
5. Provide Clear APIs
Document your store's public interface:
/**
* Theme Store
*
* State:
* - mode: 'light' | 'dark' | 'auto'
* - fontSize: 'small' | 'medium' | 'large' | 'xl'
* - primaryColor: hex color string
*
* Methods:
* - toggleMode(): Toggle between light/dark
* - setMode(mode): Set specific mode
* - setFontSize(size): Set font size
* - setPrimaryColor(color): Set primary color
* - resetToDefaults(): Reset all settings
*
* Computed:
* - computedTheme: Object with resolved theme values
*/
When NOT to Use Global State
Global state is powerful, but it's not always the right solution:
Use Local State When:
- Data is only used by one component and its children
- Data doesn't need to persist between component mounts/unmounts
- The relationship between components is clear and direct
Use Props/Events When:
- Components have a clear parent-child relationship
- Data flow is simple and unidirectional
- You want to keep components loosely coupled
Use Global State When:
- Multiple unrelated components need the same data
- Data needs to persist across navigation
- You're dealing with user preferences or application settings
- You need to coordinate state across different parts of the app
Debugging Store Issues
1. Add Logging to Store Methods
setMode(mode) {
console.log(`Theme mode changing from ${this.state.mode} to ${mode}`);
this.state.mode = mode;
this.applyTheme();
this.savePreferences();
}
2. Use Browser DevTools
Stores are regular JavaScript objects, so you can inspect them in the console:
// In browser console
const themeStore = odoo.__SERVICES__.themeStore;
console.log(themeStore.state);
3. Add Validation
setFontSize(size) {
const validSizes = ["small", "medium", "large", "xl"];
if (!validSizes.includes(size)) {
console.error(`Invalid font size: ${size}. Valid options: ${validSizes.join(', ')}`);
return;
}
this.state.fontSize = size;
this.applyTheme();
this.savePreferences();
}
Real-World Integration Example
Here is a complete example showing how multiple services work together in a production scenario:
export class DashboardStore {
constructor(orm, notification, user) {
this.orm = orm;
this.notification = notification;
this.user = user;
this.state = reactive({
// Dashboard data
metrics: {
totalSales: 0,
activeCustomers: 0,
pendingOrders: 0,
monthlyRevenue: 0
},
// Loading states
loadingMetrics: false,
loadingCharts: false,
// Dashboard configuration
layout: this.user.dashboardLayout || 'grid',
visibleWidgets: this.user.visibleWidgets || ['sales', 'customers', 'orders'],
// Chart data
salesData: [],
customerData: [],
// Filters
dateRange: 'last-month',
selectedTeam: null,
// Errors
errors: {}
});
// Load initial data
this.initialize();
}
async initialize() {
try {
await Promise.all([
this.loadMetrics(),
this.loadChartData()
]);
this.notification.add("Dashboard loaded successfully", {
type: "success"
});
} catch (error) {
this.notification.add("Failed to load dashboard", {
type: "danger",
sticky: true
});
console.error("Dashboard initialization error:", error);
}
}
The constructor builds the reactive state shape and immediately kicks off initialize(), which loads everything the dashboard needs in parallel and shows a notification either way. Next come the two loader methods that initialize() calls — each follows the same loading flag / try/catch/finally pattern you saw earlier in this chapter:
async loadMetrics() {
this.state.loadingMetrics = true;
this.state.errors.metrics = null;
try {
const metrics = await this.orm.call(
"dashboard.analytics",
"get_metrics",
[],
{
date_from: this.getDateFrom(),
date_to: this.getDateTo(),
team_id: this.state.selectedTeam
}
);
this.state.metrics = {
totalSales: metrics.total_sales,
activeCustomers: metrics.active_customers,
pendingOrders: metrics.pending_orders,
monthlyRevenue: metrics.monthly_revenue
};
} catch (error) {
this.state.errors.metrics = error.message;
console.error("Failed to load metrics:", error);
} finally {
this.state.loadingMetrics = false;
}
}
async loadChartData() {
this.state.loadingCharts = true;
this.state.errors.charts = null;
try {
const [salesData, customerData] = await Promise.all([
this.orm.call("dashboard.analytics", "get_sales_chart_data", [], {
period: this.state.dateRange
}),
this.orm.call("dashboard.analytics", "get_customer_chart_data", [], {
period: this.state.dateRange
})
]);
this.state.salesData = salesData;
this.state.customerData = customerData;
} catch (error) {
this.state.errors.charts = error.message;
console.error("Failed to load chart data:", error);
} finally {
this.state.loadingCharts = false;
}
}
With loading handled, the rest of the store is the public API other components actually call: small methods that change one piece of state and, when needed, trigger a reload or a save. Notice none of these methods reach into this.state from outside the store — every mutation goes through a named method, which is what keeps a growing store debuggable:
// Configuration change methods
changeDateRange(newRange) {
if (this.state.dateRange !== newRange) {
this.state.dateRange = newRange;
this.refreshData();
}
}
selectTeam(teamId) {
if (this.state.selectedTeam !== teamId) {
this.state.selectedTeam = teamId;
this.refreshData();
}
}
toggleWidget(widgetId) {
const widgets = [...this.state.visibleWidgets];
const index = widgets.indexOf(widgetId);
if (index > -1) {
widgets.splice(index, 1);
} else {
widgets.push(widgetId);
}
this.state.visibleWidgets = widgets;
this.saveUserConfig();
}
changeLayout(newLayout) {
this.state.layout = newLayout;
this.saveUserConfig();
}
async refreshData() {
await Promise.all([
this.loadMetrics(),
this.loadChartData()
]);
}
async saveUserConfig() {
try {
await this.orm.call("res.users", "save_dashboard_config", [], {
layout: this.state.layout,
visible_widgets: this.state.visibleWidgets
});
this.notification.add("Configuration saved", { type: "success" });
} catch (error) {
this.notification.add("Failed to save configuration", { type: "warning" });
}
}
Finally, a couple of plain helper methods and three computed getters expose derived data (is anything loading? are there errors? what's the current full configuration?) without duplicating it in state, followed by the service registration that turns this class into a singleton — a single shared instance created once and reused everywhere — that every component reaches via useService("dashboardStore"):
getDateFrom() {
const now = new Date();
switch (this.state.dateRange) {
case 'last-week':
return new Date(now.getTime() - 7 * 24 * 60 * 60 * 1000);
case 'last-month':
return new Date(now.getFullYear(), now.getMonth() - 1, now.getDate());
case 'last-quarter':
return new Date(now.getFullYear(), now.getMonth() - 3, now.getDate());
case 'last-year':
return new Date(now.getFullYear() - 1, now.getMonth(), now.getDate());
default:
return new Date(now.getFullYear(), now.getMonth() - 1, now.getDate());
}
}
getDateTo() {
return new Date();
}
// Computed getters
get hasErrors() {
return Object.keys(this.state.errors).some(key => this.state.errors[key]);
}
get isLoading() {
return this.state.loadingMetrics || this.state.loadingCharts;
}
get fullConfiguration() {
return {
layout: this.state.layout,
widgets: this.state.visibleWidgets,
filters: {
dateRange: this.state.dateRange,
team: this.state.selectedTeam
}
};
}
}
// Dashboard Store service
export const dashboardStoreService = {
dependencies: ["orm", "notification", "user"],
start(env, { orm, notification, user }) {
return new DashboardStore(orm, notification, user);
},
};
registry.category("services").add("dashboardStore", dashboardStoreService);
Common Pitfalls
1. Forgetting to Subscribe with useState
Creating the store with reactive({...}) only makes the data reactive at the source. A component that reads the store directly (useService("dashboardStore").state.metrics) without wrapping it in useState won't re-render when that data changes:
// BAD: reads the reactive state once, component never re-renders on changes
this.store = useService("dashboardStore");
// GOOD: subscribes this component to the store's reactivity
this.store = useState(useService("dashboardStore").state);
2. Mutating the Store from Anywhere
If any component can do this.store.state.selectedTeam = 5 directly, you lose track of who changes what and why. Keep every mutation behind a named method on the store (selectTeam(id)), even when the method is a one-liner — it gives you a single place to add logging, validation, or a side effect later.
3. One Store Instance per Component
Registering the store as a service (as shown above) guarantees a single shared instance. Instantiating new DashboardStore(...) directly inside a component's setup() creates a private copy that no other component can see — defeating the entire purpose of global state.
4. Reaching for Global State Too Early
Not everything needs to live in a store. If only one component (and its direct children, via props) ever reads a piece of data, local useState is simpler and easier to reason about. Promote state to a store only when two or more unrelated components genuinely need to share it.
Global state management with stores is essential for building complex, interconnected Odoo applications. By centralizing shared state and providing clear APIs for accessing and modifying it, you create applications that are more maintainable, more predictable, and easier to debug.
The key is knowing when to use global state versus local state, and designing your stores with clear responsibilities and clean interfaces. Master this pattern, and you'll be able to build sophisticated applications that scale gracefully as they grow in complexity.
In the next chapter, we'll explore how to ensure your components work correctly through testing and effective debugging techniques.
TL;DR: Register a reactive() store as a service and consume it with useState(useService(...).state) wherever it's needed, so unrelated components can share reactive state without prop drilling.
Try it yourself: This chapter's example is available as an installable Odoo 19 addon: simplifyit_owl_book_ch15_ex1. Installation instructions are in the repository README.
Exercises
- Shared counter store. Create a
counterStoreservice usingreactive({ count: 0 })withincrement()anddecrement()methods. Consume it from two sibling components withuseState(useService("counterStore").state)and confirm that clicking a button in one updates the count shown in the other. - Add a computed getter. Extend the store from Exercise 1 with a
get isPositive()getter, and use it in a template to conditionally show a warning when the count goes negative. - Local vs. global. Take a component you built in an earlier chapter's exercises (or the
DataTablefrom Chapter 14) and decide: does any of its state genuinely need to be global? Write one sentence justifying your answer before writing any code.
What's Next?
State that's correct isn't the same as state you can trust in front of a client — Chapter 16 Part 1 covers how to test and debug OWL components so you catch regressions before they do.