What Makes a Custom Hook
A custom hook is any function that:
- Is called inside
setup()(or in another custom hook called fromsetup()) - Calls OWL hook functions like
useState,onMounted,onWillUnmount,useRef - Returns values or methods the component will use
By convention, custom hook function names start with use.
import { useState } from "@odoo/owl";
// Custom hook: encapsulates a toggle with on/off helpers
function useToggle(initialValue = false) {
const state = useState({ value: initialValue });
return {
get value() { return state.value; },
on() { state.value = true; },
off() { state.value = false; },
toggle() { state.value = !state.value; },
};
}
// Usage in any component:
class MyComponent extends Component {
setup() {
this.menu = useToggle(false);
this.modal = useToggle(false);
this.darkMode = useToggle(true);
}
static template = xml`
<div>
<button t-on-click="menu.toggle">Toggle Menu</button>
<nav t-if="menu.value">…</nav>
</div>
`;
}
Hooks with Lifecycle
import { useRef, onMounted } from "@odoo/owl";
/**
* Auto-focuses the element with the given t-ref name after mount.
* Usage: const ref = useAutoFocus("myInput");
* Template: <input t-ref="myInput"/>
*/
function useAutoFocus(refName = "autofocus") {
const ref = useRef(refName);
onMounted(() => {
ref.el?.focus();
});
return ref; // return so component can still use the ref
}
// ─── useWindowSize: reactive window dimensions ─────────────────────
function useWindowSize() {
const size = useState({ width: window.innerWidth, height: window.innerHeight });
const onResize = () => {
size.width = window.innerWidth;
size.height = window.innerHeight;
};
onMounted(() => window.addEventListener("resize", onResize));
onWillUnmount(() => window.removeEventListener("resize", onResize));
return size; // reactive — component re-renders on window resize
}
Practical Custom Hooks
useDebounce
import { onWillUnmount } from "@odoo/owl";
/**
* Returns a debounced version of fn that fires after `delay` ms of inactivity.
*/
function useDebounce(fn, delay = 300) {
let timerId = null;
onWillUnmount(() => clearTimeout(timerId)); // cancel on unmount
return function debounced(...args) {
clearTimeout(timerId);
timerId = setTimeout(() => fn(...args), delay);
};
}
// Usage:
class SearchInput extends Component {
state = useState({ query: "", results: [] });
setup() {
// search() fires only after user stops typing for 400ms
this.debouncedSearch = useDebounce(this.search.bind(this), 400);
}
async search() {
this.state.results = await fetchResults(this.state.query);
}
static template = xml`
<input t-model="state.query" t-on-input="debouncedSearch"/>
`;
}
useLocalStorage
import { useState } from "@odoo/owl";
/**
* Persists a state value to localStorage. Reads initial value from storage.
*/
function useLocalStorage(key, defaultValue) {
const stored = localStorage.getItem(key);
const initial = stored !== null ? JSON.parse(stored) : defaultValue;
const state = useState({ value: initial });
// Wrap in a Proxy-like setter that also writes to storage
const set = (newValue) => {
state.value = newValue;
localStorage.setItem(key, JSON.stringify(newValue));
};
return {
get value() { return state.value; },
set,
};
}
// Usage:
class ThemeToggle extends Component {
setup() {
this.theme = useLocalStorage("app-theme", "light");
}
toggle() {
this.theme.set(this.theme.value === "light" ? "dark" : "light");
}
static template = xml`
<button t-on-click="toggle">
Current: <t t-esc="theme.value"/>
</button>
`;
}
usePagination
import { useState } from "@odoo/owl";
function usePagination({ pageSize = 10 } = {}) {
const state = useState({ page: 1, pageSize });
return {
get page() { return state.page; },
get pageSize() { return state.pageSize; },
get offset() { return (state.page - 1) * state.pageSize; },
next() { state.page++; },
prev() { if (state.page > 1) state.page--; },
goTo(n) { state.page = Math.max(1, n); },
reset() { state.page = 1; },
totalPages(totalCount) {
return Math.ceil(totalCount / state.pageSize);
},
};
}
// Usage:
class RecordList extends Component {
setup() {
this.pagination = usePagination({ pageSize: 20 });
onWillStart(() => this.load());
}
async load() {
const { offset, pageSize } = this.pagination;
this.state.records = await this.orm.searchRead(
"res.partner", [], ["name"], { offset, limit: pageSize }
);
}
}
Hook Rules
| Rule | Why |
|---|---|
Call hooks synchronously in setup() only | OWL's current-component context only exists during setup |
| Never call hooks inside conditionals or loops | Hook registration order must be consistent across renders |
Name hooks starting with use | Convention — signals it must be called in setup() |
| Custom hooks can call other custom hooks | Composability — each registers its own lifecycle callbacks |
| Return reactive state or methods, not DOM elements | DOM not available during setup; use onMounted for DOM work |
📋 Summary
- Custom hooks are plain functions starting with
usethat call OWL hook functions internally. - They must be called synchronously inside
setup()— calling hooks outside setup throws. - Multiple registrations of the same lifecycle hook (from different custom hooks) all fire — this is intentional.
- Custom hooks can return reactive state, computed values, methods, or refs.
- Real-world patterns:
useToggle,useAutoFocus,useDebounce,useLocalStorage,usePagination,useWindowSize. - Odoo's built-in
useService(),useModel(),useEnv()are all custom hooks — exactly the same pattern.
🏋️ Exercise
Write three custom hooks and use them together in one component:
useCounter(initial, step)— returns{ value, increment, decrement, reset }. UsesuseState.useKeyPress(key, callback)— callscallbackwhen the given key is pressed. UsesonMountedandonWillUnmountto add/remove akeydownlistener onwindow.useTitle(title)— setsdocument.titletotitleon mount and resets it on unmount. UsesonMountedandonWillUnmount.- Build a
ScoreBoardcomponent that uses all three: a counter for score, ArrowUp/ArrowDown keys increment/decrement viauseKeyPress, anduseTitlekeeps the browser tab title in sync with the score.
FAQ
Yes — this is one of the main powers of custom hooks. A custom hook calling another custom hook is fully supported as long as the call chain originates from setup(). OWL's "current component" context propagates through the synchronous call stack. This is how Odoo's hooks like useOrmService can internally call useService("orm") — it just works because the context is set when setup() is running.
A utility function is called anywhere and has no access to the component context. A custom hook is called in setup() and registers callbacks on the component's lifecycle. The distinction: if your function calls onMounted, useState, or any OWL hook, it is a custom hook and must be called in setup. If it is pure computation (format a date, validate an email), it is a utility and can be called anywhere.