Advertisement
🦉 Advanced OWL

OWL JS Custom Hooks – Writing Reusable Hook Functions

Custom hooks are plain JavaScript functions that call OWL hook functions internally — useState, onMounted, onWillUnmount, useRef, and others. They let you extract repeated lifecycle and state logic out of components and share it across your codebase without creating a new component. This is the most powerful abstraction pattern in OWL 2 — the same mechanism that makes Odoo's built-in useService(), useModel(), and useEnv() work. This lesson teaches you to write custom hooks from scratch.

⏱️ 22 min read 🎯 Intermediate 📅 Updated 2026 👁️ Lesson 3 of 6

What Makes a Custom Hook

A custom hook is any function that:

  • Is called inside setup() (or in another custom hook called from setup())
  • 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.

JavaScript – minimal custom hook
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

JavaScript – useAutoFocus hook
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
}
Advertisement

Practical Custom Hooks

useDebounce

JavaScript – 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

JavaScript – 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

JavaScript – 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

RuleWhy
Call hooks synchronously in setup() onlyOWL's current-component context only exists during setup
Never call hooks inside conditionals or loopsHook registration order must be consistent across renders
Name hooks starting with useConvention — signals it must be called in setup()
Custom hooks can call other custom hooksComposability — each registers its own lifecycle callbacks
Return reactive state or methods, not DOM elementsDOM not available during setup; use onMounted for DOM work

Hooks: Reusable Logic, and the Rules That Keep Them Working

Hooks are functions (named use*) that plug into a component's lifecycle and reactivity. OWL ships several; you can also write your own to share logic between components.

HookGives you
useState(obj)reactive state — mutations re-render
useRef("name")direct handle to a DOM element
onWillStart(fn)async setup before first render
onMounted(fn)run after DOM is in the page
useEnv()access shared services/env

Writing a custom hook

function useAutoTitle(getText) {
    onMounted(() => { document.title = getText(); });
    onPatched(() => { document.title = getText(); });
}
// in setup(): useAutoTitle(() => this.state.name);

The one rule: hooks must be called synchronously inside setup() (or inside another hook). Never call a hook in an event handler, a condition, or after an await — OWL relies on call order to associate each hook with the component. Break that and you get "No active component" errors or state bound to the wrong instance.

🏋️ Practical Exercise

Write three custom hooks and use them together in one component:

  1. useCounter(initial, step) — returns { value, increment, decrement, reset }. Uses useState.
  2. useKeyPress(key, callback) — calls callback when the given key is pressed. Uses onMounted and onWillUnmount to add/remove a keydown listener on window.
  3. useTitle(title) — sets document.title to title on mount and resets it on unmount. Uses onMounted and onWillUnmount.
  4. Build a ScoreBoard component that uses all three: a counter for score, ArrowUp/ArrowDown keys increment/decrement via useKeyPress, and useTitle keeps the browser tab title in sync with the score.

🔥 Challenge Exercise

Write a custom hook (e.g. useCounter or useWindowSize) that encapsulates reusable stateful logic, and use it in two components. Explain what hooks are in OWL, that they must be called from setup, and how they enable logic reuse.

📋 Summary

  • Custom hooks are plain functions starting with use that 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.

Interview Questions

  • What is a hook in OWL?
  • Where must hooks be called?
  • How do you write a custom hook?
  • Why are hooks better than mixins for reuse?
  • Name three built-in OWL hooks.

FAQ

Can I call a custom hook inside another custom hook? +

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.

How are custom hooks different from utility functions? +

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.