Building a Custom Vue 3 Hook Using the Composition API

Hero image for Building a Custom Vue 3 Hook Using the Composition API. Image by Shana Van Roosbroek.
Hero image for 'Building a Custom Vue 3 Hook Using the Composition API.' Image by Shana Van Roosbroek.

In Vue 3, the usual term for a reusable stateful helper is composable rather than hook. Still, the underlying idea is familiar: we want a small reusable unit of logic that can be dropped into multiple components without dragging UI concerns along with it.

That pattern is one of the biggest strengths of the Composition API. It lets us isolate behaviour into small functions that are easier to read, easier to test, and easier to scale across a growing application.


What Makes a Good Composable

A good composable usually has a narrow responsibility. It owns one concern, exposes a clear API, and keeps side effects predictable.

That means the composable should answer a simple question. Are we encapsulating pointer tracking, form submission state, outsideclick handling, connection status, or fetch state? If the answer starts becoming "all of the modal behaviour, some analytics, and a bit of business logic too", the extraction probably needs tightening up.

This design has a quiet functional flavour to it. We compose behaviour out of smaller pieces, keep data flow explicit, and lean on small units that are easy to reason about. That is where composables stop feeling like a naming convention and start feeling like architecture.


Building a useOutsideClick Composable

import { onMounted, onUnmounted, ref, type Ref } from 'vue';type MaybeElement = HTMLElement | null;export const useOutsideClick = (  onOutsideClick: () => void): {  target: Ref<MaybeElement>;} => {  const target = ref<MaybeElement>(null);  const handlePointerDown = (event: MouseEvent): void => {    const element = target.value;    if (!element) {      return;    }    if (event.target instanceof Node && !element.contains(event.target)) {      onOutsideClick();    }  };  onMounted(() => {    window.addEventListener('mousedown', handlePointerDown);  });  onUnmounted(() => {    window.removeEventListener('mousedown', handlePointerDown);  });  return { target };};

This composable does one job. It exposes a target ref for the consuming component, attaches one listener, and delegates the outsideclick behaviour through a callback.

That last part is important. The composable is not deciding what "close" means. It is only deciding when an outside click has happened. The component still owns the UI state change, which is exactly the kind of boundary we want.


Using It in a Component

<script setup lang="ts">import { ref } from 'vue';import { useOutsideClick } from '@/composables/useOutsideClick';const isOpen = ref(false);const { target } = useOutsideClick(() => {  isOpen.value = false;});</script><template>  <section ref="target">    <button type="button" @click="isOpen = !isOpen">Toggle menu</button>    <div v-if="isOpen">Menu content</div>  </section></template>

The component remains focused on presentation, whilst the composable owns the reusable interaction logic. That separation is exactly what improves maintainability.


Shape the API Before You Extract

One of the easiest mistakes with composables is extracting too early and exporting whatever internal state happened to exist at the time. That often leaves us with an awkward API that leaks implementation detail.

Before extracting, it is worth asking:

  • what does the consumer actually need back?
  • which arguments should be explicit inputs?
  • which side effects belong inside the composable?
  • will this still make sense if three components use it slightly differently?

Those questions usually lead to a smaller and better API. In this example, returning `target` makes sense because the consumer needs to attach it. Returning the raw event listener would not.


Browser‑Only Side Effects Need Care

Composables often touch browser APIs such as window, document, IntersectionObserver, or localStorage. That is perfectly reasonable, but we should still be deliberate about where that work happens.

The useful rule is simple: if the code depends on the browser, keep it inside lifecycle hooks such as onMounted() so it does not run too early. That matters for SSR environments, tests, and any code path that may execute before the DOM is available.

This is one reason Vue's own examples lean on lifecycle hooks inside composables. The pattern is not ceremony. It is what keeps the reusable logic safe across different runtime environments.


Design Choices That Improve Testability

  • Keep arguments explicit rather than reading global state implicitly.
  • Return a small, stable API.
  • Clean up side effects in onUnmounted().
  • Avoid mixing business logic and DOM concerns inside the same composable unless they genuinely belong together.

These choices matter when the codebase grows. A composable that is easy to test in isolation is much easier to trust when several components depend on it.

They also make refactoring cheaper. If the composable exposes a small, honest contract, the consuming components can evolve without being tightly coupled to how the internal logic happens to work today.


When a Composable is the Wrong Abstraction

It is easy to think that every repeated line of code deserves a composable. That is not quite right. If the logic is trivial, extraction may only add indirection.

A related assumption is that composables should hide all implementation detail. In reality, a composable should expose the right implementation detail, enough to be useful, but not so much that consumers become tightly coupled to its internals.

It is also worth distinguishing composables from directives and components:

  • use a composable when the reusable concern is stateful logic
  • use a directive when the concern is direct DOM manipulation on one element
  • use a component when the reuse includes rendered structure as well as behaviour

That boundary keeps the abstractions from overlapping in messy ways.

Vue's own documentation is the most useful place to dig further into these APIs:


Wrapping up

Building a good Vue 3 hook, or composable, is really about drawing a clean boundary around behaviour. When we keep the responsibility narrow, manage side effects carefully, and expose a clear API, composables become one of the most effective tools we have for keeping Vue applications modular and scalable.

Key Takeaways

  • In Vue, composable is usually the more precise term than hook.
  • A strong composable owns one concern and exposes a small API.
  • Browseronly side effects should stay inside lifecycle hooks.
  • Side effects, cleanup, and testability should shape the design from the start.

Once we treat composables as architectural building blocks rather than clever abstractions, they become much more reliable and much easier to reuse.


Categories:

  1. Development
  2. Front‑End Development
  3. Guides
  4. JavaScript
  5. Vue.js