onWillUnmount Basics
onWillUnmount fires synchronously before the component is removed from the DOM. The element this.el is still accessible here β it is removed from the document after the hook completes.
import { Component, xml, onMounted, onWillUnmount } from "@odoo/owl";
class MyComponent extends Component {
setup() {
onMounted(() => {
// Create something that needs cleanup
this._timerId = setInterval(() => {
console.log("tick");
}, 1000);
});
onWillUnmount(() => {
// Clean it up before the component is destroyed
clearInterval(this._timerId);
});
}
}
The golden rule: if you create it in onMounted, destroy it in onWillUnmount. This applies to: timers (setInterval, setTimeout), global event listeners (window.addEventListener), DOM observers (ResizeObserver, IntersectionObserver, MutationObserver), WebSocket connections, third-party library instances, and pub/sub subscriptions.
Complete Cleanup Patterns
Timers: setInterval and setTimeout
class AutoRefresh extends Component {
state = useState({ data: null, lastUpdated: null });
setup() {
onMounted(() => {
// Start polling every 30 seconds
this._pollInterval = setInterval(() => {
this.refresh();
}, 30_000);
// Also set a one-shot timeout
this._introTimeout = setTimeout(() => {
this.state.showIntro = false;
}, 5_000);
});
onWillUnmount(() => {
clearInterval(this._pollInterval); // stop polling
clearTimeout(this._introTimeout); // cancel pending timeout
});
}
async refresh() {
this.state.data = await fetchData();
this.state.lastUpdated = new Date();
}
}
Global Event Listeners
class ModalWithEscape extends Component {
setup() {
// Store the bound reference β needed for removeEventListener
this._onKeyDown = (ev) => {
if (ev.key === "Escape") {
this.props.onClose();
}
};
onMounted(() => {
// Listen globally for Escape key
window.addEventListener("keydown", this._onKeyDown);
// Prevent body scroll while modal is open
document.body.style.overflow = "hidden";
});
onWillUnmount(() => {
// Must pass the exact same function reference to remove it
window.removeEventListener("keydown", this._onKeyDown);
// Restore body scroll
document.body.style.overflow = "";
});
}
}
removeEventListener requires the exact same function reference that was passed to addEventListener. If you bind in onMounted (this.method.bind(this)), that creates a new function β you cannot remove it unless you stored the result. The cleanest pattern: create the bound reference in setup() as a property, then use it in both onMounted and onWillUnmount.
DOM Observers
class LazyImage extends Component {
static template = xml`
<div t-ref="container">
<img t-if="state.isVisible" t-att-src="props.src" t-att-alt="props.alt"/>
<div t-else="" class="placeholder">Loadingβ¦</div>
</div>
`;
containerRef = useRef("container");
state = useState({ isVisible: false });
setup() {
onMounted(() => {
// IntersectionObserver: load image only when it scrolls into view
this._observer = new IntersectionObserver(
([entry]) => {
if (entry.isIntersecting) {
this.state.isVisible = true;
this._observer.disconnect(); // stop watching after first intersection
}
},
{ threshold: 0.1 }
);
this._observer.observe(this.containerRef.el);
});
onWillUnmount(() => {
// Stop observing β prevents callback firing after component is gone
this._observer?.disconnect();
});
}
}
class ResizableWidget extends Component {
state = useState({ width: 0 });
setup() {
onMounted(() => {
this._resizeObserver = new ResizeObserver(([entry]) => {
this.state.width = entry.contentRect.width;
});
this._resizeObserver.observe(this.el);
});
onWillUnmount(() => {
this._resizeObserver?.disconnect();
});
}
}
Pub/Sub Subscriptions
/** @odoo-module **/
import { Component, xml, useState, onWillStart, onWillUnmount } from "@odoo/owl";
import { useService } from "@web/core/utils/hooks";
class NotificationBell extends Component {
state = useState({ count: 0 });
setup() {
this.bus = useService("bus_service");
onWillStart(() => {
// Subscribe to the Odoo bus
this.bus.subscribe("notification_count", this._onNotification.bind(this));
});
onWillUnmount(() => {
// Unsubscribe to prevent ghost callbacks
this.bus.unsubscribe("notification_count", this._onNotification.bind(this));
// Better: store the bound ref
});
}
_onNotification({ count }) {
this.state.count = count;
}
}
The Stale Component Problem
A subtle but important bug: an async operation started while the component was alive completes after the component is unmounted. Setting state on an unmounted component causes errors and wasted work.
class SearchPanel extends Component {
state = useState({ results: [], isLoading: false });
_isMounted = false;
setup() {
onMounted(() => {
this._isMounted = true;
});
onWillUnmount(() => {
this._isMounted = false;
// Also cancel pending requests if using AbortController
this._abortController?.abort();
});
}
async search(query) {
this._abortController = new AbortController();
this.state.isLoading = true;
try {
const results = await fetchSearch(query, {
signal: this._abortController.signal, // cancel on unmount
});
if (!this._isMounted) return; // component gone β do nothing
this.state.results = results;
this.state.isLoading = false;
} catch (err) {
if (err.name === "AbortError") return; // expected β not an error
if (!this._isMounted) return;
this.state.isLoading = false;
}
}
}
onError β Error Boundaries
onError makes a component an error boundary β it catches errors thrown by any descendant component and lets the parent decide how to recover.
import { Component, xml, useState, onError } from "@odoo/owl";
class ErrorBoundary extends Component {
static template = xml`
<div>
<t t-if="state.hasError">
<div class="error-fallback">
<h3>Something went wrong</h3>
<p t-esc="state.errorMessage"/>
<button t-on-click="retry">Try Again</button>
</div>
</t>
<t t-else="">
<t t-slot="default"/> <!-- normal children render here -->
</t>
</div>
`;
state = useState({ hasError: false, errorMessage: "" });
setup() {
onError((error) => {
console.error("Caught by ErrorBoundary:", error);
this.state.hasError = true;
this.state.errorMessage = error.message || "An unexpected error occurred.";
});
}
retry() {
this.state.hasError = false;
this.state.errorMessage = "";
// Re-mounting the children requires remounting the whole boundary β
// change a key prop or use a state-driven re-render
}
}
// Parent β wraps risky components
class App extends Component {
static components = { ErrorBoundary, RiskyWidget };
static template = xml`
<ErrorBoundary>
<RiskyWidget/>
</ErrorBoundary>
`;
}
π Summary
onWillUnmountfires synchronously before the component is removed. Use it to destroy everything created inonMounted.- Must clean up:
setInterval/setTimeout,window.addEventListener,ResizeObserver/IntersectionObserver/MutationObserver, WebSocket connections, third-party library instances, pub/sub subscriptions. - Store bound function references as instance properties so
removeEventListenercan find the exact same reference. - For async operations that outlive the component, use an
AbortControllerto cancel them and an_isMountedflag to guard state updates. onErrorturns a component into an error boundary β it catches errors from all descendant components and can show a fallback UI instead of crashing the whole app.
ποΈ Exercise
Build a WebSocketMonitor component to practice all unmount cleanup patterns:
- In
onMounted, open a WebSocket connection (usenew WebSocket("wss://echo.websocket.org")or simulate with a mock). Store the connection onthis._ws. - On message, push the message text into
state.messages. Guard against updates after unmount. - In
onMountedalso start asetIntervalthat sends a "ping" every 5 seconds. - In
onWillUnmount: close the WebSocket (this._ws?.close()) and clear the interval. - Wrap the component in a parent with a "Connect / Disconnect" toggle that mounts/unmounts
WebSocketMonitorviat-if. Open browser DevTools β Network to confirm the WebSocket closes cleanly on unmount.
Frequently Asked Questions
Yes β event listeners added declaratively with t-on-* in the template are managed by OWL. They are added when the element is rendered and removed when the component unmounts or the element is removed from the template (e.g., via t-if). You only need to manually clean up listeners added with addEventListener in your code β typically in onMounted for window or document listeners.
Several problems: (1) Memory leaks β the component's closure keeps all referenced objects alive in memory even after unmount. In long-running Odoo sessions where views are opened and closed repeatedly, this adds up. (2) Ghost callbacks β a setInterval or WebSocket message handler fires after unmount and tries to mutate useState on a destroyed component, causing warnings or errors. (3) Duplicate listeners β if the component re-mounts (toggle with t-if), onMounted adds another listener without removing the previous one.
OWL does not await onWillUnmount. If you pass an async function, OWL calls it but does not wait for the Promise to resolve before removing the component from the DOM. Cleanup must complete synchronously. For async cleanup (like flushing a pending analytics batch), start the async operation but do not block unmounting on it. The DOM removal happens regardless.
try/catch in onWillStart catches errors from that specific async hook in that specific component. onError is an error boundary β it catches errors thrown by any descendant component's lifecycle hooks, renders, or unhandled Promises during OWL's rendering. Use try/catch for expected errors in a specific operation. Use onError when you want a parent to act as a safety net for an entire subtree β hiding failure from the user and showing a fallback.