Ad – 728×90
🦉 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
}
Ad – 336×280

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

📋 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.

🏋️ 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.

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.