Why this chapter? Lifecycle hooks alone push you toward writing code organized by "when," not "why" — a timer's setup ends up in
onMounted, its cleanup inonWillUnmount, and the two drift apart as the component grows. Modern hooks let you keep everything about one concern together. What is Odoo trying to solve with this? Odoo's own backend components juggle a lot of external resources — DOM measurements, subscriptions, timers, focus management — anduseEffect/useRefgive a single, predictable place to set them up and tear them down without scattering that logic across multiple lifecycle methods. Real-world application: Debounced search boxes, auto-focus on a newly opened dialog, and syncing a chart library to component state are all things I've shipped in client addons using exactly the patterns in this chapter.
In Chapter 10, we explored the lifecycle hooks like onMounted, onWillStart, and onWillUnmount. These hooks are powerful and intuitive, but modern OWL development encourages a more functional and flexible approach using modern hooks, primarily useEffect and useRef.
Modern hooks represent a paradigm shift in how we think about component logic. Instead of organizing code by lifecycle events ("when the component mounts, do this"), we organize it by features or concerns ("everything related to this timer lives together"). This approach makes code more maintainable, testable, and easier to reason about.
Let's dive deep into these modern hooks and learn how they can make your components more powerful and elegant.
useRef: Beyond DOM References
We briefly encountered useRef in Chapter 10 for accessing DOM elements. While DOM access is its most common use case, useRef is actually a much more versatile hook for creating stable references to any value.
Understanding Refs
In OWL, a ref is an object with a single .el property that points to the DOM element marked with the matching t-ref directive in your template. The key characteristic of a ref is that it's stable across re-renders—it always points to the same object instance, and .el is kept up to date as the DOM changes.
Coming from React? OWL has no "value refs" with a
.currentproperty. It doesn't need them: OWL components are class instances, so for mutable values that shouldn't trigger re-renders (timer IDs, previous values, library instances) you simply use plain instance properties likethis.timer.useRefin OWL is exclusively for DOM access.
DOM References: The Classic Use Case
Let's start with the familiar DOM reference pattern:
JavaScript (focus_manager.js):
import { Component, useRef, useState } from "@odoo/owl";
export class FocusManager extends Component {
static template = "my_module.FocusManager";
setup() {
this.state = useState({
inputValue: "",
focusCount: 0
});
// Create refs for multiple elements
this.nameInputRef = useRef("name-input");
this.emailInputRef = useRef("email-input");
this.submitButtonRef = useRef("submit-button");
}
focusName() {
if (this.nameInputRef.el) {
this.nameInputRef.el.focus();
this.state.focusCount++;
console.log("FocusManager: Focused name input");
}
}
focusEmail() {
if (this.emailInputRef.el) {
this.emailInputRef.el.focus();
this.state.focusCount++;
console.log("FocusManager: Focused email input");
}
}
focusSubmit() {
if (this.submitButtonRef.el) {
this.submitButtonRef.el.focus();
this.state.focusCount++;
console.log("FocusManager: Focused submit button");
}
}
handleKeyDown(event) {
// Navigate between fields with Tab
if (event.key === "Tab" && event.shiftKey) {
// Handle custom tab navigation if needed
console.log("FocusManager: Shift+Tab pressed");
}
}
onInputChange(event) {
this.state.inputValue = event.target.value;
// Auto-focus next field when current is filled
if (event.target === this.nameInputRef.el && event.target.value.length > 2) {
setTimeout(() => this.focusEmail(), 100);
}
}
}
Template (focus_manager.xml):
<?xml version="1.0" encoding="UTF-8"?>
<templates xml:space="preserve">
<t t-name="my_module.FocusManager" owl="1">
<div class="focus-manager card">
<div class="card-header">
<h5>Focus Manager Demo</h5>
<small class="text-muted">Focus events: <t t-esc="state.focusCount"/></small>
</div>
<div class="card-body">
<form t-on-keydown="handleKeyDown">
<div class="mb-3">
<label for="name" class="form-label">Name</label>
<input
type="text"
class="form-control"
t-ref="name-input"
placeholder="Enter your name"
t-model="state.inputValue"
t-on-input="onInputChange"
/>
</div>
<div class="mb-3">
<label for="email" class="form-label">Email</label>
<input
type="email"
class="form-control"
t-ref="email-input"
placeholder="Enter your email"
/>
</div>
<button
type="submit"
class="btn btn-primary me-2"
t-ref="submit-button">
Submit
</button>
</form>
<div class="mt-3">
<small class="text-muted">Quick Focus:</small><br/>
<button class="btn btn-sm btn-outline-secondary me-1" t-on-click="focusName">
Focus Name
</button>
<button class="btn btn-sm btn-outline-secondary me-1" t-on-click="focusEmail">
Focus Email
</button>
<button class="btn btn-sm btn-outline-secondary" t-on-click="focusSubmit">
Focus Submit
</button>
</div>
</div>
</div>
</t>
</templates>
Storing Mutable Data: Plain Instance Properties
What about mutable values that should not trigger re-renders when changed—timer IDs, previous values for comparison, third-party library instances? In OWL you don't need a hook for these at all. Because your component is a class instance, a plain property does the job:
Example: Timer with Instance Properties
import { Component, useState } from "@odoo/owl";
export class TimerComponent extends Component {
static template = "my_module.TimerComponent";
setup() {
this.state = useState({
seconds: 0,
isRunning: false
});
// Plain instance properties - changing them never causes a re-render
this.timer = null; // timer ID
this.previousSeconds = 0; // previous value for comparison
this.startCount = 0; // how many times the timer was started
}
startTimer() {
if (this.state.isRunning) return;
console.log("TimerComponent: Starting timer");
this.state.isRunning = true;
this.startCount += 1;
// Store timer ID on the instance - this won't trigger re-render
this.timer = setInterval(() => {
this.state.seconds++;
// Log every 10 seconds using previous value comparison
if (this.state.seconds % 10 === 0 && this.state.seconds !== this.previousSeconds) {
console.log(`TimerComponent: ${this.state.seconds} seconds elapsed`);
this.previousSeconds = this.state.seconds;
}
}, 1000);
}
stopTimer() {
if (!this.state.isRunning) return;
console.log("TimerComponent: Stopping timer");
this.state.isRunning = false;
// Clear timer using stored ID
if (this.timer) {
clearInterval(this.timer);
this.timer = null;
}
}
resetTimer() {
this.stopTimer();
this.state.seconds = 0;
this.previousSeconds = 0;
console.log("TimerComponent: Timer reset");
}
get timerStats() {
return {
currentTime: this.formatTime(this.state.seconds),
startCount: this.startCount,
isActive: this.timer !== null
};
}
formatTime(seconds) {
const mins = Math.floor(seconds / 60);
const secs = seconds % 60;
return `${mins.toString().padStart(2, '0')}:${secs.toString().padStart(2, '0')}`;
}
}
The rule of thumb: reactive data that the template shows goes in useState; everything else is a plain instance property; useRef is only for DOM elements.
useEffect: The Swiss Army Knife of Side Effects
The useEffect hook is the most powerful and flexible hook in modern OWL. It replaces multiple lifecycle hooks with a single, unified API that groups related logic together.
What's a "side effect"? It's anything a piece of code does that reaches outside of computing its return value — starting a timer, adding an event listener, calling the server, changing
document.title. Rendering should be a pure calculation of "state in, DOM out";useEffectis where you put the parts that aren't.
Understanding Dependencies
The magic of useEffect lies in its second parameter: a dependency function that returns the list of values the effect depends on. OWL calls this function on every render and re-runs the effect only when one of the returned values changed.
Careful if you know React: in React the dependencies are a plain array (
[state.count]). In OWL they are a function that returns an array (() => [state.count]). Passing a plain array is a common migration bug.
// Runs once after mount, cleanup on unmount (like onMounted + onWillUnmount)
useEffect(
() => {
console.log("Component mounted");
},
() => []
);
// Runs after every render (usually not what you want) - omit the dependency function
useEffect(() => {
console.log("Component rendered");
});
// Runs when specific values change
useEffect(
() => {
console.log("Count or name changed");
},
() => [this.state.count, this.state.name]
);
Pattern 1: Mount-Only Effects (Replacing onMounted)
Let's create a component that sets up multiple things when it mounts:
JavaScript (dashboard.js):
import { Component, useEffect, useRef, useState } from "@odoo/owl";
import { useService } from "@web/core/utils/hooks";
export class Dashboard extends Component {
static template = "my_module.Dashboard";
setup() {
this.state = useState({
currentTime: new Date().toLocaleTimeString(),
stats: {
users: 0,
orders: 0,
revenue: 0
},
loading: true
});
this.orm = useService("orm");
this.titleRef = useRef("page-title");
// Mount-only effect - runs once after component mounts
useEffect(() => {
console.log("Dashboard: Component mounted, initializing...");
// 1. Set up clock
const clockInterval = setInterval(() => {
this.state.currentTime = new Date().toLocaleTimeString();
}, 1000);
// 2. Focus the title
if (this.titleRef.el) {
this.titleRef.el.focus();
}
// 3. Load initial data
this.loadDashboardData();
// 4. Set up window focus listener
const handleWindowFocus = () => {
console.log("Dashboard: Window focused, refreshing data");
this.loadDashboardData();
};
window.addEventListener('focus', handleWindowFocus);
// Cleanup function - runs when component unmounts
return () => {
console.log("Dashboard: Cleaning up mount effects");
clearInterval(clockInterval);
window.removeEventListener('focus', handleWindowFocus);
};
}, () => []); // Empty dependency list = run once on mount
}
async loadDashboardData() {
try {
console.log("Dashboard: Loading dashboard statistics");
// Simulate loading multiple stats in parallel
const [users, orders] = await Promise.all([
this.orm.searchCount("res.users", []),
this.orm.searchCount("sale.order", [["state", "=", "sale"]])
]);
this.state.stats = {
users,
orders,
revenue: orders * 150 // Simulated revenue calculation
};
this.state.loading = false;
console.log("Dashboard: Statistics loaded successfully");
} catch (error) {
console.error("Dashboard: Failed to load statistics:", error);
this.state.loading = false;
}
}
}
Pattern 2: Reactive Effects (Replacing onWillUpdateProps)
Effects can react to specific state or prop changes:
JavaScript (user_profile.js):
import { Component, useEffect, useState } from "@odoo/owl";
import { useService } from "@web/core/utils/hooks";
export class UserProfile extends Component {
static template = "my_module.UserProfile";
static props = {
userId: { type: Number }
};
setup() {
this.state = useState({
user: null,
loading: false,
error: null,
loadCount: 0
});
this.orm = useService("orm");
// Effect that reacts to userId prop changes
useEffect(() => {
if (!this.props.userId) {
console.log("UserProfile: No user ID provided");
this.state.user = null;
return;
}
console.log(`UserProfile: Loading user data for ID ${this.props.userId}`);
this.loadUserData(this.props.userId);
}, () => [this.props.userId]); // Runs when userId changes
// Separate effect for logging load attempts
useEffect(() => {
if (this.state.loadCount > 0) {
console.log(`UserProfile: Load attempt #${this.state.loadCount}`);
}
}, () => [this.state.loadCount]);
}
async loadUserData(userId) {
this.state.loading = true;
this.state.error = null;
this.state.loadCount += 1;
try {
const users = await this.orm.read("res.users", [userId], [
"name",
"email",
"phone",
"partner_id",
"login_date"
]);
if (users.length > 0) {
this.state.user = users[0];
console.log(`UserProfile: Successfully loaded ${users[0].name}`);
} else {
throw new Error(`User ${userId} not found`);
}
} catch (error) {
console.error(`UserProfile: Failed to load user ${userId}:`, error);
this.state.error = error.message;
this.state.user = null;
} finally {
this.state.loading = false;
}
}
}
Pattern 3: Multiple Independent Effects
One of the biggest advantages of useEffect is that you can have multiple effects, each handling a specific concern:
JavaScript (notification_center.js):
First, the component sets up its state and its first effect, which only cares about whether the browser is online:
import { Component, useEffect, useState } from "@odoo/owl";
import { useService } from "@web/core/utils/hooks";
export class NotificationCenter extends Component {
static template = "my_module.NotificationCenter";
setup() {
this.state = useState({
notifications: [],
connectionStatus: 'connecting',
lastUpdate: null,
unreadCount: 0
});
this.orm = useService("orm");
// Effect 1: Connection management
useEffect(() => {
console.log("NotificationCenter: Setting up connection monitoring");
const checkConnection = () => {
this.state.connectionStatus = navigator.onLine ? 'connected' : 'disconnected';
};
// Initial check
checkConnection();
// Set up listeners
window.addEventListener('online', checkConnection);
window.addEventListener('offline', checkConnection);
return () => {
window.removeEventListener('online', checkConnection);
window.removeEventListener('offline', checkConnection);
};
}, () => []);
Next, a second effect watches connectionStatus and only starts polling for new notifications once the browser is actually online — notice its dependency function returns [this.state.connectionStatus], so it re-runs whenever that value flips:
// Effect 2: Periodic notification refresh
useEffect(() => {
if (this.state.connectionStatus !== 'connected') {
console.log("NotificationCenter: Skipping refresh - not connected");
return;
}
console.log("NotificationCenter: Setting up periodic refresh");
const refreshInterval = setInterval(() => {
this.refreshNotifications();
}, 30000); // Refresh every 30 seconds
// Initial load
this.refreshNotifications();
return () => {
clearInterval(refreshInterval);
};
}, () => [this.state.connectionStatus]); // Re-run when connection status changes
Finally, a third, completely independent effect recalculates the unread count and keeps the browser tab title in sync whenever the notification list changes — it doesn't know or care about the connection logic above:
// Effect 3: Unread count calculation
useEffect(() => {
const unread = this.state.notifications.filter(n => !n.is_read).length;
this.state.unreadCount = unread;
console.log(`NotificationCenter: ${unread} unread notifications`);
// Update browser title if there are unread notifications
if (unread > 0) {
document.title = `(${unread}) Odoo - Notifications`;
} else {
document.title = "Odoo";
}
return () => {
// Cleanup title on unmount
document.title = "Odoo";
};
}, () => [this.state.notifications.length]);
}
async refreshNotifications() {
try {
console.log("NotificationCenter: Refreshing notifications");
const notifications = await this.orm.searchRead(
"mail.notification",
[["res_partner_id", "=", this.currentUserId]],
["id", "mail_message_id", "is_read", "create_date"],
{
order: "create_date desc",
limit: 50
}
);
this.state.notifications = notifications;
this.state.lastUpdate = new Date();
} catch (error) {
console.error("NotificationCenter: Failed to refresh notifications:", error);
}
}
get currentUserId() {
// This would come from a user service in a real app
return 1; // Simplified for example
}
}
Three effects, three concerns, zero tangled logic — that's the payoff of organizing by feature instead of by lifecycle moment.
Advanced useEffect Patterns
Pattern: Effect with Async Logic
useEffect(() => {
let cancelled = false;
const fetchData = async () => {
try {
const result = await this.orm.searchRead("some.model", [], []);
// Only update state if effect hasn't been cancelled
if (!cancelled) {
this.state.data = result;
}
} catch (error) {
if (!cancelled) {
console.error("Fetch failed:", error);
}
}
};
fetchData();
// Cleanup: mark as cancelled to prevent state updates
return () => {
cancelled = true;
};
}, () => [this.state.someDependency]);
Pattern: Debounced Effect
Debouncing means delaying a piece of work until a burst of triggering changes has paused for a set amount of time — instead of searching on every keystroke, you wait until the user stops typing for 300ms.
useEffect(() => {
const timeoutId = setTimeout(() => {
// Perform expensive operation only after value has been stable for 300ms
this.performSearch(this.state.searchTerm);
}, 300);
return () => {
clearTimeout(timeoutId);
};
}, () => [this.state.searchTerm]);
Combining useRef and useEffect: Powerful Patterns
When you combine useRef and useEffect, you unlock some very powerful patterns:
Pattern: Previous Value Comparison
setup() {
this.state = useState({ count: 0 });
this.previousCount = undefined; // plain instance property
useEffect(
() => {
console.log(`Count changed from ${this.previousCount} to ${this.state.count}`);
// Store current value for next comparison
this.previousCount = this.state.count;
},
() => [this.state.count]
);
}
Pattern: Third-Party Library Integration
setup() {
this.chartCanvasRef = useRef("chart-canvas"); // DOM ref (t-ref="chart-canvas")
this.chart = null; // plain property for the library instance
this.state = useState({ data: [] });
// Set up chart on mount
useEffect(
() => {
if (!this.chartCanvasRef.el) return;
const ctx = this.chartCanvasRef.el.getContext('2d');
this.chart = new Chart(ctx, {
type: 'line',
data: { datasets: [] }
});
return () => {
if (this.chart) {
this.chart.destroy();
}
};
},
() => []
);
// Update chart when data changes
useEffect(
() => {
if (this.chart) {
this.chart.data = this.processChartData();
this.chart.update();
}
},
() => [this.state.data.length]
);
}
Migration Guide: From Lifecycle to Modern Hooks
If you have existing components using lifecycle hooks, here's how to migrate them:
Before: Lifecycle Hooks
setup() {
this.state = useState({ data: null });
this.orm = useService("orm");
onWillStart(async () => {
this.state.data = await this.orm.searchRead("model", [], []);
});
onMounted(() => {
document.title = "My Component";
this.timer = setInterval(this.refresh, 5000);
});
onWillUnmount(() => {
document.title = "Odoo";
if (this.timer) {
clearInterval(this.timer);
}
});
}
After: Modern Hooks
setup() {
this.state = useState({ data: null });
this.orm = useService("orm");
// Keep onWillStart for pre-render data: useEffect runs AFTER mounting,
// so replacing it with an effect would bring back the empty-then-flicker render
onWillStart(async () => {
this.state.data = await this.orm.searchRead("model", [], []);
});
// Mount effects (replaces onMounted + onWillUnmount)
useEffect(
() => {
document.title = "My Component";
const timer = setInterval(this.refresh, 5000);
return () => {
document.title = "Odoo";
clearInterval(timer);
};
},
() => []
);
}
Note that onWillStart stays: it's the only hook that can delay the first render, so it has no useEffect equivalent.
Environment Hooks: useEnv, useSubEnv, useChildSubEnv
Props are great for passing data one level down, from a parent to its direct child. But what about data that dozens of components scattered across the tree all need—the current user, a translation function, feature flags? Passing it down as a prop through every intermediate component (prop drilling, the same problem that motivates the global state store in Chapter 15) gets painful fast.
OWL's answer is the environment (env): a plain object shared by every component in the tree, set once when the application is mounted and available everywhere without threading it through props.
useEnv() reads the current component's environment:
import { Component, useEnv } from "@odoo/owl";
class UserBadge extends Component {
static template = xml`<span t-esc="env.currentUser.name"/>`;
setup() {
this.env = useEnv();
}
}
Sometimes a component wants to add or override something in the environment for its own subtree—a theme, a scoped configuration—without changing it for the whole app. Two hooks do this, and the difference between them matters:
useSubEnv(newValues)— mergesnewValuesinto the environment for this component and all of its children.useChildSubEnv(newValues)— mergesnewValuesinto the environment for only the children, leaving this component's ownenvuntouched.
setup() {
// Every component below this one (children, grandchildren, ...) now
// sees env.theme === "dark". This component itself does too, because
// useSubEnv affects the caller as well.
useSubEnv({ theme: "dark" });
}
Use useChildSubEnv when a component wants to configure its descendants without implying that the same configuration applies to itself—for example, a <Section title="..."> component that sets a heading level for its children without becoming a heading itself.
useExternalListener: Listening Outside the Component
Chapter 1 attached a click listener by hand with addEventListener, and had to remember to call removeEventListener in onWillUnmount to avoid a leak. useExternalListener does both steps for you in one call, which is exactly what you want for listening on window or document—targets outside the component's own DOM that OWL doesn't manage.
import { Component, useExternalListener } from "@odoo/owl";
class DropdownMenu extends Component {
setup() {
// Automatically added on mount and removed on unmount—no
// onMounted/onWillUnmount pair to write or forget.
useExternalListener(window, "click", this.closeIfOutside.bind(this));
}
closeIfOutside(ev) {
if (!this.el.contains(ev.target)) {
this.state.open = false;
}
}
}
It takes the target (window, document, or any DOM node), the event name, and a handler, and forwards any extra arguments to addEventListener—so useExternalListener(window, "keydown", this.onKeyDown, { capture: true }) works too.
One more hook worth knowing about, even though you'll rarely call it directly: useComponent() returns the current component instance. It exists mainly as a building block for writing your own custom hooks that need to reach the calling component—application code almost never needs it.
Best Practices and Common Pitfalls
Do's
- Group related logic together - Put setup and cleanup for the same feature in one effect
- Use multiple effects - Separate concerns into different effects
- Include all dependencies - The dependency function should return every reactive value the effect relies on
- Return cleanup functions - Prevent memory leaks by cleaning up resources
- Use the right storage - Reactive template data in
useState, mutable non-reactive values (timer IDs, previous values, library instances) as plain instance properties,useRefonly for DOM elements
Don'ts
- Don't pass a plain array as dependencies - OWL expects a function returning an array (
() => [..]), not the array itself - Don't use effects for everything - Some logic belongs in event handlers, not effects
- Don't replace
onWillStartwith an effect - Effects run after mounting; onlyonWillStartcan delay the first render - Don't over-complicate - Sometimes lifecycle hooks are clearer for simple cases
Common Pitfall: Array Instead of Function
// BAD: React-style plain array - OWL will not track these dependencies
useEffect(() => {
this.syncWithServer(this.state.filter);
}, [this.state.filter]);
// GOOD: a function that returns the dependency array
useEffect(
() => {
this.syncWithServer(this.state.filter);
},
() => [this.state.filter]
);
Why a function? OWL calls it on every render to get fresh values and compare them with the previous ones. A plain array would be evaluated only once, when setup() ran.
Common Pitfall: Expecting useSubEnv Not to Touch the Caller
// This component's OWN env.theme also becomes "dark" here, not just
// its children's—useSubEnv always includes the caller.
useSubEnv({ theme: "dark" });
If you want to configure descendants without changing anything for the current component itself, use useChildSubEnv instead. Reaching for the wrong one of the two is an easy, easy-to-miss bug: everything below the component behaves correctly, but the component's own rendering unexpectedly changes too.
Summary
Modern hooks represent a significant evolution in how we write components:
useRefcreates stable references to DOM elements or any mutable valueuseEffecthandles all side effects with precise control over when they run- Dependency arrays make effects predictable and performant
- Cleanup functions prevent memory leaks and resource conflicts
The key insight is organizational: instead of thinking "what happens when the component mounts," think "what does this feature need to work properly?" Group all the logic for each feature—setup, updates, and cleanup—into focused effects.
This approach makes your components more maintainable, testable, and easier to understand. In the next chapter, we'll explore how to communicate with the server using services, building on the solid foundation of modern hooks we've established here.
TL;DR: useRef gives you a stable handle to a DOM element or a mutable value; useEffect runs (and cleans up) side effects based on an explicit dependency function, so you group setup and teardown for one concern in one place instead of spreading it across lifecycle hooks.
Try it yourself: This chapter's example is available as an installable Odoo 19 addon: simplifyit_owl_book_ch11_ex1. Installation instructions are in the repository README.
Exercises
- Focus-on-mount: Build a small component with a text input and a
useRef. UseuseEffectwith an empty dependency function (() => []) to focus the input as soon as the component mounts. - Fix the dependency bug: Take this broken snippet and fix it so the effect actually re-runs when
state.querychanges:javascript useEffect(() => { console.log("Searching for", this.state.query); }, [this.state.query]); - Debounced counter: Add a
useEffectthat logs"Stable!"to the console only afterstate.counthasn't changed for 500ms (hint:setTimeoutin the effect,clearTimeoutin the cleanup function — the same shape as the debounced search example above).
What's Next?
You now have components that manage their own DOM references and side effects — but real Odoo addons rarely live in isolation from the server. Chapter 12 covers orm and rpc, the services that let your components read and write actual business data.