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

    Chapter 15: Global State Management with useStore

  • All Blogs
  • OWL Book
  • Chapter 15: Global State Management with useStore
  • August 25, 2026 by
    Chapter 15: Global State Management with useStore
    Grover Menacho

    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:

    1. Storing user data at the top level (App component)
    2. Passing it down through Header → UserMenu
    3. Passing it down through Main → Sidebar → QuickActions
    4. Passing it down through Main → Content → Dashboard
    5. Passing update functions up from ProfileForm through Content → Main → App
    6. 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

    Prop drilling versus a store shared as a service.{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:

    1. Reactive State: Uses OWL's reactive function to make data changes trigger component updates
    2. Centralized Logic: All operations that modify the state are contained within the store
    3. Service Integration: Registered as an Odoo service, making it available throughout the application
    4. 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

    1. Shared counter store. Create a counterStore service using reactive({ count: 0 }) with increment() and decrement() methods. Consume it from two sibling components with useState(useService("counterStore").state) and confirm that clicking a button in one updates the count shown in the other.
    2. 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.
    3. Local vs. global. Take a component you built in an earlier chapter's exercises (or the DataTable from 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.

    in OWL Book
    Chapter 16 Part 1: Testing and Debugging Fundamentals
    Reading the whole book? Take it with you — free, no sign-up.
    PDF EPUB All chapters
    Useful Links
    • Home
    • About us
    • Blog
    • The OWL 2.0 book
    • Help
    • Contact us
    About us

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

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

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