| |

Vue.js 49 🟢 Code Reuse Patterns: Extracting Custom Composables

⏱️ Estimated read: 18 min | |

A Vue component is a function that returns a UI. A composable is a function that returns state and behavior, without the UI. The two are the same kind of thing, and the distinction is where the logic lives. When the logic is inside a component, it is bound to that component’s template. When the logic is inside a composable, it can be used by any component, tested in isolation, and composed with other composables. The Composition API is what makes this possible: the same reactivity primitives that a component uses are available to a plain function, and a composable is nothing more than a function that uses them.

Key point: A composable is a function whose name starts with use and that returns reactive state, a set of methods, or both. It is called from the setup function or from <script setup>, and the reactivity it creates is bound to the calling component’s scope. When the component unmounts, the composable’s effects are cleaned up automatically.


Why composables exist

The mixin problem. In Vue 2, the reuse mechanism was the mixin. A mixin merged its options into the component, and every property became part of the component’s namespace. Two mixins with a data property of the same name collided silently, and the source of a property was impossible to trace. Composables replace mixins with explicit imports and return values.

The renderless-component problem. In Vue 2, another reuse mechanism was the renderless component, a component that took a slot and passed it state through slot props. The pattern worked but was verbose, and the state was scoped to a component instance rather than to the parent’s scope.

The duplication problem. Two components that both fetch a user, both track a window size, or both manage a form have the same logic written twice. When the logic changes, both copies must be updated. A composable is a single source of truth for the logic.

The testability problem. Logic inside a component is tested through the component’s mount, which pulls in the template and the DOM. Logic inside a composable is tested by calling the function, which is faster and more focused.

The composition problem. A composable can call other composables. A useUser composable can use useFetch and useAuth. The composition is explicit, and each piece is independent.

The state-scope problem. A composable that creates a ref creates a ref scoped to the calling component. Two components that call the same composable get two independent refs. A composable that shares state must use a module-level ref or a store, and the distinction is explicit.

The SSR problem. A composable that uses onMounted runs only on the client. A composable that uses onServerPrefetch runs on the server. The lifecycle hooks available to a composable are the same as those available to a component, and the SSR-safety considerations are the same.


a. The basic composable

A composable is a function that uses the reactivity primitives and returns what the caller needs. The name starts with use by convention.

The mouse tracker

The canonical example is a mouse tracker. The composable registers a mousemove listener and returns the coordinates.

// composables/useMouse.js
import { ref, onMounted, onUnmounted } from 'vue';

export function useMouse() {
  const x = ref(0);
  const y = ref(0);

  function update(event) {
    x.value = event.pageX;
    y.value = event.pageY;
  }

  onMounted(() => window.addEventListener('mousemove', update));
  onUnmounted(() => window.removeEventListener('mousemove', update));

  return { x, y };
}

The composable uses ref to create the state, onMounted to register the listener, and onUnmounted to clean it up. It returns the refs, and the caller destructures them.

<script setup>
import { useMouse } from '@/composables/useMouse';

const { x, y } = useMouse();
</script>

<template>
  <p>Mouse: {{ x }}, {{ y }}</p>
</template>

The refs are bound to the calling component. When the component unmounts, the listener is removed.

The return shape

A composable returns an object, not an array. The object form is preferred because the caller can destructure only what it needs, and the properties are named.

// ✅ Object return
return { x, y };

// ❌ Array return
return [x, y];

The object return also allows the composable to add new properties without breaking the callers.

The refs versus reactive

A composable can return refs or a reactive object. The ref form is more common because the caller can destructure the refs without losing reactivity.

// ✅ Refs
const x = ref(0);
return { x };

// ❌ Reactive
const state = reactive({ x: 0 });
return { state };
// The caller must use state.x, not destructure.

A reactive object destructured loses reactivity, which is a common source of bugs. The ref form avoids the problem.

The lifecycle hooks

A composable can use any lifecycle hook, and the hook is bound to the calling component. The onMounted and onUnmounted hooks are the most common, but onActivated, onDeactivated, and the rest are also available.

export function useInterval(callback, delay) {
  let id = null;

  onMounted(() => {
    id = setInterval(callback, delay);
  });

  onUnmounted(() => {
    clearInterval(id);
  });
}

The cleanup is automatic. The caller does not need to remember it.

The getCurrentInstance caveat

A composable that uses a lifecycle hook must be called during the component’s setup. Calling it outside the setup, such as in an event handler, produces a warning because the hook has no component to bind to.

The async composable

A composable can be async, but the state it returns must be set after the await. The caller receives the refs immediately and uses them when they resolve.

export function useUser(id) {
  const user = ref(null);
  const loading = ref(true);

  fetch(`/api/users/${id}`)
    .then((r) => r.json())
    .then((data) => {
      user.value = data;
      loading.value = false;
    });

  return { user, loading };
}

The composable returns the refs synchronously. The loading ref tracks the pending state.


b. The common composable patterns

The composables below appear in almost every Vue application. Each one is a small, focused function.

The useFetch

A composable that wraps a fetch and returns the data, the error, and the loading state.

// composables/useFetch.js
import { ref, watchEffect } from 'vue';

export function useFetch(url) {
  const data = ref(null);
  const error = ref(null);
  const loading = ref(false);

  async function fetchData() {
    loading.value = true;
    error.value = null;
    try {
      const response = await fetch(url.value);
      if (!response.ok) throw new Error(response.statusText);
      data.value = await response.json();
    } catch (err) {
      error.value = err;
    } finally {
      loading.value = false;
    }
  }

  watchEffect(fetchData);

  return { data, error, loading };
}

The watchEffect re-fetches when the url ref changes. The composable returns the three refs.

The useLocalStorage

A composable that syncs a ref with localStorage.

// composables/useLocalStorage.js
import { ref, watch } from 'vue';

export function useLocalStorage(key, defaultValue) {
  const stored = localStorage.getItem(key);
  const value = ref(stored ? JSON.parse(stored) : defaultValue);

  watch(value, (newValue) => {
    localStorage.setItem(key, JSON.stringify(newValue));
  }, { deep: true });

  return value;
}

The composable returns a single ref. Writing to the ref writes to localStorage.

The useDebounce

A composable that debounces a ref.

// composables/useDebounce.js
import { ref, watch } from 'vue';

export function useDebounce(source, delay = 300) {
  const debounced = ref(source.value);
  let timer = null;

  watch(source, (newValue) => {
    clearTimeout(timer);
    timer = setTimeout(() => {
      debounced.value = newValue;
    }, delay);
  });

  return debounced;
}

The composable returns a ref that lags behind the source by the delay.

The useToggle

A composable that manages a boolean with a toggle function.

// composables/useToggle.js
import { ref } from 'vue';

export function useToggle(initial = false) {
  const value = ref(initial);

  function toggle() {
    value.value = !value.value;
  }

  function setTrue() {
    value.value = true;
  }

  function setFalse() {
    value.value = false;
  }

  return { value, toggle, setTrue, setFalse };
}

The composable returns the ref and the methods. The caller decides which to use.

The useEventListener

A composable that registers an event listener with automatic cleanup.

// composables/useEventListener.js
import { onMounted, onUnmounted } from 'vue';

export function useEventListener(target, event, handler) {
  onMounted(() => target.value?.addEventListener(event, handler));
  onUnmounted(() => target.value?.removeEventListener(event, handler));
}

The composable is a wrapper around the standard pattern. It takes a ref to the target element.

The useForm

A composable that manages a form’s state, validation, and submission.

// composables/useForm.js
import { reactive, ref } from 'vue';

export function useForm(initialValues, submitFn) {
  const values = reactive({ ...initialValues });
  const errors = reactive({});
  const submitting = ref(false);

  function reset() {
    Object.assign(values, initialValues);
    Object.keys(errors).forEach((k) => delete errors[k]);
  }

  async function handleSubmit() {
    submitting.value = true;
    Object.keys(errors).forEach((k) => delete errors[k]);
    try {
      await submitFn(values);
    } catch (err) {
      Object.assign(errors, err.fieldErrors ?? {});
    } finally {
      submitting.value = false;
    }
  }

  return { values, errors, submitting, handleSubmit, reset };
}

The composable returns the values, the errors, the submitting flag, and the two methods.

The composable composition

A composable can call other composables. The useUser composable calls useFetch and useAuth.

// composables/useUser.js
import { computed } from 'vue';
import { useFetch } from './useFetch';
import { useAuth } from './useAuth';

export function useUser() {
  const { token } = useAuth();
  const url = computed(() => `/api/me?token=${token.value}`);
  const { data, error, loading } = useFetch(url);

  const user = computed(() => data.value?.user ?? null);

  return { user, error, loading };
}

The composition is explicit, and each piece is independent.


c. The conventions and the caveats

The composables have conventions and caveats. The conventions are what make the API predictable, and the caveats are what prevent the common mistakes.

The naming convention

A composable’s name starts with use. The convention signals that the function uses the reactivity primitives and must be called from the setup.

// ✅ Good
export function useMouse() { }
export function useFetch(url) { }

// ❌ Bad
export function mouse() { }
export function fetchData(url) { }

The return convention

A composable returns an object with the refs and the methods. The object form is preferred over the array form.

// ✅ Object return
return { x, y };

// ❌ Array return
return [x, y];

The reactivity convention

A composable returns refs, not reactive objects. The ref form allows the caller to destructure without losing reactivity.

// ✅ Refs
const data = ref(null);
return { data };

// ❌ Reactive
const state = reactive({ data: null });
return { state };

The lifecycle convention

A composable that registers a lifecycle hook must be called during the setup. Calling it from an event handler or an async callback produces a warning.

// ✅ Inside setup
onMounted(() => {
  useMouse();
});

// ❌ Inside a callback
button.addEventListener('click', () => {
  useMouse();  // Warning
});

The shared-state caveat

A composable that creates a ref inside the function creates a ref scoped to the calling component. Two components that call the same composable get two independent refs. A composable that shares state must use a module-level ref.

// ✅ Component-scoped
export function useCounter() {
  const count = ref(0);
  return { count };
}

// ✅ Module-scoped (shared)
const sharedCount = ref(0);
export function useSharedCounter() {
  return { count: sharedCount };
}

The two patterns are different, and the distinction must be explicit.

The SSR caveat

A composable that uses onMounted runs only on the client. A composable that must run on the server uses onServerPrefetch or runs the logic during the setup.

The cleanup caveat

A composable that registers a listener must remove it in the matching cleanup hook. The cleanup is the composable’s responsibility, not the caller’s.

// ✅ Cleanup in the composable
onMounted(() => window.addEventListener('mousemove', update));
onUnmounted(() => window.removeEventListener('mousemove', update));

The getCurrentInstance caveat

A composable that uses a lifecycle hook must be called during the setup. The getCurrentInstance function returns the current instance or null.

import { getCurrentInstance } from 'vue';

export function useThing() {
  if (!getCurrentInstance()) {
    throw new Error('useThing must be called from setup');
  }
  // ...
}

The check is optional but useful for libraries.

The types convention

In TypeScript, a composable returns a typed object. The types are inferred from the refs.

export function useCounter(initial = 0) {
  const count = ref<number>(initial);
  function increment() { count.value++; }
  return { count, increment };
}

The caller receives the typed object without an explicit annotation.


Complete Example Session

// ============================================
// PART 1: THE BASIC COMPOSABLE
// ============================================

import { ref, onMounted, onUnmounted } from 'vue';

export function useMouse() {
  const x = ref(0);
  const y = ref(0);

  function update(event) {
    x.value = event.pageX;
    y.value = event.pageY;
  }

  onMounted(() => window.addEventListener('mousemove', update));
  onUnmounted(() => window.removeEventListener('mousemove', update));

  return { x, y };
}

// ============================================
// PART 2: THE USEFETCH COMPOSABLE
// ============================================

import { ref, watchEffect } from 'vue';

export function useFetch(url) {
  const data = ref(null);
  const error = ref(null);
  const loading = ref(false);

  async function fetchData() {
    loading.value = true;
    error.value = null;
    try {
      const response = await fetch(url.value);
      if (!response.ok) throw new Error(response.statusText);
      data.value = await response.json();
    } catch (err) {
      error.value = err;
    } finally {
      loading.value = false;
    }
  }

  watchEffect(fetchData);

  return { data, error, loading };
}

// ============================================
// PART 3: THE USELOCALSTORAGE COMPOSABLE
// ============================================

import { ref, watch } from 'vue';

export function useLocalStorage(key, defaultValue) {
  const stored = localStorage.getItem(key);
  const value = ref(stored ? JSON.parse(stored) : defaultValue);

  watch(value, (v) => {
    localStorage.setItem(key, JSON.stringify(v));
  }, { deep: true });

  return value;
}

// ============================================
// PART 4: THE USEDEBOUNCE COMPOSABLE
// ============================================

import { ref, watch } from 'vue';

export function useDebounce(source, delay = 300) {
  const debounced = ref(source.value);
  let timer = null;

  watch(source, (newValue) => {
    clearTimeout(timer);
    timer = setTimeout(() => {
      debounced.value = newValue;
    }, delay);
  });

  return debounced;
}

// ============================================
// PART 5: THE USETOGGLE COMPOSABLE
// ============================================

import { ref } from 'vue';

export function useToggle(initial = false) {
  const value = ref(initial);
  function toggle() { value.value = !value.value; }
  return { value, toggle };
}

// ============================================
// PART 6: THE USEFORM COMPOSABLE
// ============================================

import { reactive, ref } from 'vue';

export function useForm(initialValues, submitFn) {
  const values = reactive({ ...initialValues });
  const errors = reactive({});
  const submitting = ref(false);

  async function handleSubmit() {
    submitting.value = true;
    try {
      await submitFn(values);
    } catch (err) {
      Object.assign(errors, err.fieldErrors ?? {});
    } finally {
      submitting.value = false;
    }
  }

  return { values, errors, submitting, handleSubmit };
}

// ============================================
// PART 7: THE COMPOSABLE COMPOSITION
// ============================================

import { computed } from 'vue';
import { useFetch } from './useFetch';
import { useAuth } from './useAuth';

export function useUser() {
  const { token } = useAuth();
  const url = computed(() => `/api/me?token=${token.value}`);
  const { data, error, loading } = useFetch(url);
  const user = computed(() => data.value?.user ?? null);
  return { user, error, loading };
}

// ============================================
// PART 8: THE COMPONENT USAGE
// ============================================

// App.vue
<script setup>
import { useMouse } from '@/composables/useMouse';
import { useFetch } from '@/composables/useFetch';
import { computed, toRef } from 'vue';

const { x, y } = useMouse();

const url = toRef('https://api.example.com/data');
const { data, error, loading } = useFetch(url);
</script>

<template>
  <p>Mouse: {{ x }}, {{ y }}</p>
  <p v-if="loading">Loading...</p>
  <p v-else-if="error">{{ error.message }}</p>
  <pre v-else>{{ data }}</pre>
</template>

// ============================================
// PART 9: THE SHARED-STATE COMPOSABLE
// ============================================

import { ref } from 'vue';

const sharedCount = ref(0);

export function useSharedCounter() {
  function increment() { sharedCount.value++; }
  return { count: sharedCount, increment };
}

// ============================================
// PART 10: THE COMPLETE PATTERN
// ============================================

// 1. Name the composable with the use prefix.
// 2. Return an object, not an array.
// 3. Return refs, not reactive objects.
// 4. Register the lifecycle hooks inside the composable.
// 5. Clean up in the matching hook.
// 6. Use module-level refs for shared state.
// 7. Compose composables by calling them.
// 8. Call from setup, not from callbacks.

The ten parts showed the basic composable, useFetch, useLocalStorage, useDebounce, useToggle, useForm, the composition, the component usage, the shared-state composable, and the complete pattern.


Quick Reference

The Composable Conventions

ConventionValue
Name prefixuse
Return shapeObject
Return typeRefs
LifecycleBound to the caller
CleanupInside the composable

The Common Composables

ComposableReturns
useMouse{ x, y }
useFetch{ data, error, loading }
useLocalStorageA ref
useDebounceA ref
useToggle{ value, toggle }
useForm{ values, errors, submitting, handleSubmit }

The Return Types

TypeWhen
refThe state is a value
computedThe state is derived
FunctionThe behavior
ObjectAlways the container

The State Scope

PatternScope
ref inside the functionPer component
ref at module levelShared
provide/injectPer subtree
Pinia storeGlobal

The Caveats

CaveatFix
Lifecycle outside setupCall during setup
Lost reactivityReturn refs, not reactive
Shared state confusionUse module-level refs
Missing cleanupClean up in the hook
Array returnUse an object

Best Practices

✅ Do This:

// Name the composable with use.
export function useMouse() { }  // ✅

// Return an object.
return { x, y };  // ✅

// Return refs.
const data = ref(null);
return { data };  // ✅

// Register the lifecycle hooks inside.
onMounted(() => window.addEventListener('mousemove', update));  // ✅

// Clean up in the matching hook.
onUnmounted(() => window.removeEventListener('mousemove', update));  // ✅

// Use module-level refs for shared state.
const shared = ref(0);
export function useShared() { return { shared }; }  // ✅

// Compose composables.
const { data } = useFetch(url);  // ✅

// Call from setup.
const { x, y } = useMouse();  // ✅

❌ Don’t Do This:

// Don't use a name without the use prefix.
export function mouse() { }  // ❌

// Don't return an array.
return [x, y];  // ❌

// Don't return a reactive object.
const state = reactive({ x: 0 });
return { state };  // ❌ Loses reactivity on destructure

// Don't forget the cleanup.
onMounted(() => window.addEventListener('mousemove', update));  // ❌ No removal

// Don't call a composable outside setup.
button.addEventListener('click', () => {
  useMouse();  // ❌ Warning
});

// Don't create shared state inside the function.
export function useCounter() {
  const count = ref(0);  // ❌ Per-component, not shared
  return { count };
}  // ❌

Common Pitfalls

PitfallWhy It HappensFix
Lost reactivityDestructured reactiveReturn refs
Warning on lifecycleCalled outside setupCall during setup
Memory leakNo cleanupClean up in the hook
State not sharedref inside the functionUse module-level ref
Array destructuringReturned an arrayReturn an object
Stale dataonMounted onlyUse watchEffect
Type errorNo genericAnnotate the ref

Real-World Examples

1. useMouse

export function useMouse() {
  const x = ref(0), y = ref(0);
  // ...
  return { x, y };
}

2. useFetch

export function useFetch(url) {
  const data = ref(null);
  const loading = ref(false);
  // ...
  return { data, loading };
}

3. useLocalStorage

export function useLocalStorage(key, defaultValue) {
  const value = ref(localStorage.getItem(key) ?? defaultValue);
  return value;
}

4. useDebounce

export function useDebounce(source, delay = 300) {
  // ...
  return debounced;
}

5. useToggle

export function useToggle(initial = false) {
  const value = ref(initial);
  return { value, toggle: () => value.value = !value.value };
}

6. useForm

export function useForm(initial, submitFn) {
  const values = reactive({ ...initial });
  return { values, handleSubmit };
}

7. Composition

export function useUser() {
  const { data } = useFetch('/api/me');
  return { user: computed(() => data.value?.user) };
}

8. Shared State

const shared = ref(0);
export function useShared() {
  return { shared };
}

9. Component Usage

<script setup>
const { x, y } = useMouse();
</script>

10. Cleanup

onUnmounted(() => window.removeEventListener('mousemove', update));

Visual

The Composable Structure

┌────────────────────────────────────────────────────────────┐
│  THE COMPOSABLE STRUCTURE                                  │
│                                                            │
│  ┌──────────────────────────────────────────────────┐     │
│  │  export function useMouse() {                     │     │
│  │    const x = ref(0);        ← state               │     │
│  │    const y = ref(0);                              │     │
│  │                                                   │     │
│  │    function update(e) { }   ← behavior            │     │
│  │                                                   │     │
│  │    onMounted(() => { });    ← lifecycle           │     │
│  │    onUnmounted(() => { });                        │     │
│  │                                                   │     │
│  │    return { x, y };         ← return              │     │
│  │  }                                                │     │
│  └──────────────────────────────────────────────────┘     │
│                                                            │
│  The composable has state, behavior, lifecycle, and return.│
└────────────────────────────────────────────────────────────┘

The Component Usage

┌────────────────────────────────────────────────────────────┐
│  THE COMPONENT USAGE                                       │
│                                                            │
│  <script setup>                                            │
│    import { useMouse } from '@/composables/useMouse';      │
│    const { x, y } = useMouse();                            │
│  </script>                                                 │
│                                                            │
│  ┌──────────────────────────────────────────────────┐     │
│  │  The component calls the composable.              │     │
│  │  The composable creates the refs.                 │     │
│  │  The refs are bound to the component's scope.     │     │
│  │  When the component unmounts, the cleanup runs.   │     │
│  └──────────────────────────────────────────────────┘     │
└────────────────────────────────────────────────────────────┘

The Scope

┌────────────────────────────────────────────────────────────┐
│  THE SCOPE                                                 │
│                                                            │
│  PER-COMPONENT                                             │
│  ┌──────────────────────────────────────────────────┐     │
│  │  export function useCounter() {                   │     │
│  │    const count = ref(0);   ← new ref per call     │     │
│  │    return { count };                              │     │
│  │  }                                                │     │
│  │                                                   │     │
│  │  Two components get two independent counts.       │     │
│  └──────────────────────────────────────────────────┘     │
│                                                            │
│  SHARED                                                    │
│  ┌──────────────────────────────────────────────────┐     │
│  │  const shared = ref(0);    ← one ref              │     │
│  │  export function useShared() {                    │     │
│  │    return { shared };                             │     │
│  │  }                                                │     │
│  │                                                   │     │
│  │  Two components get the same count.               │     │
│  └──────────────────────────────────────────────────┘     │
│                                                            │
│  The scope is determined by where the ref is created.      │
└────────────────────────────────────────────────────────────┘

The Composition

┌────────────────────────────────────────────────────────────┐
│  THE COMPOSITION                                           │
│                                                            │
│  useUser                                                   │
│    ├─ useAuth                                              │
│    │    └─ returns { token }                               │
│    ├─ useFetch                                             │
│    │    └─ returns { data, error, loading }                │
│    └─ computed                                             │
│         └─ returns { user }                                │
│                                                            │
│  ┌──────────────────────────────────────────────────┐     │
│  │  A composable can call other composables.         │     │
│  │  Each piece is independent and testable.          │     │
│  │  The composition is explicit.                     │     │
│  └──────────────────────────────────────────────────┘     │
└────────────────────────────────────────────────────────────┘

Summary

ItemValue
ComposableA function that returns reactive state and behavior
Name prefixuse
Return shapeObject
Return typeRefs and functions
LifecycleBound to the calling component
CleanupAutomatic when the component unmounts
Shared stateModule-level refs
CompositionA composable can call other composables
TestCall the function directly
ScopeDetermined by where the ref is created

Key takeaways:

  • A composable is a function that uses the reactivity primitives. It returns refs, methods, or both, and it is called from the setup. The reactivity is bound to the calling component’s scope, and the cleanup is automatic.
  • The name starts with use. The convention signals that the function uses the reactivity primitives and must be called from the setup. It also distinguishes composables from plain utility functions.
  • The return is an object, not an array. The object form allows the caller to destructure only what it needs, and the properties are named. The array form is less flexible.
  • The return values are refs, not reactive objects. A reactive object destructured loses its reactivity. A ref can be destructured without losing anything.
  • The lifecycle hooks are bound to the caller. A composable that uses onMounted registers the hook on the calling component. When the component unmounts, the cleanup runs automatically.
  • The scope is determined by where the ref is created. A ref inside the composable’s function is per-component. A ref at the module level is shared. The distinction must be explicit.
  • A composable can call other composables. The composition is explicit, and each piece is independent and testable.
  • The reactive destructuring trap is the most common pitfall. The ref return form avoids it. The reactive form requires the caller to keep the object intact.

Remember: A composable is the Composition API’s answer to the mixin and the renderless component. It is a function that returns reactive state and behavior, named with the use prefix and called from the setup. The refs it creates are bound to the calling component’s scope, and the lifecycle hooks it registers are cleaned up automatically. The shared state uses module-level refs, and the composition uses other composables. The result is a piece of logic that is reused across components, tested in isolation, and composed with other pieces, and a codebase where the logic is a named function instead of a copy inside a template.


Was this helpful?


Stop using slow, ad-bloated tool sites! 🤮

🔎 Search “KandZ Tools” on Google to use many professional utilities for free.

KandZ.me is the ultimate minimalist hub for:
✅ Finance (Mortgage, Interest, Inflation)
✅ Tech (Base64, JSON, Dev Suite, IP)
✅ Health (BMI, BMR, TDEE)
✅ Productivity (Timer, Workspace, QR)

⚡️ Fast & Private
🔒 No data leaves your device
💎 100% Free

🔗 Use it now: https://tools.kandz.me
🔖 Bookmark it—you’ll need it later!