frontend / vue / 16_composables.md

Composables and VueUse

5 interview angles 4 min read source

Composables and VueUse

A composable is a function that uses Vue’s reactivity APIs to encapsulate stateful logic. It is Vue’s answer to React’s custom hooks, and it is the main unit of reuse in a Composition API codebase.

The shape

// useCounter.js
import { ref, computed } from 'vue';

export function useCounter(initial = 0) {
  const count = ref(initial);
  const double = computed(() => count.value * 2);
  function increment() { count.value++; }
  return { count, double, increment };
}

Conventions that make it recognisable as one:

  • Name starts with use.
  • Called synchronously at the top of <script setup> or setup() — not in a condition, loop, or callback, if it registers lifecycle hooks or watch.
  • Returns refs and functions, not a reactive object, so the caller can destructure without losing reactivity.

How this differs from React hooks

This is the comparison interviewers reach for, and getting it right signals you understand both models.

React hook Vue composable
Runs every render once, at setup
Identified by call order (hence the rules of hooks) nothing — it is a plain function call
Stale closures a constant hazard mostly absent; refs are live boxes
Dependency arrays required for useEffect/useMemo none; tracking is automatic
Conditional calls never allowed allowed, unless it registers lifecycle hooks

Because the setup function runs once, the closure problem that dominates React debugging largely disappears — a composable reads count.value at the moment it runs, not a value frozen at render time. See ../05_react/stale_closures_and_hook_rules.md for what you are avoiding.

Accepting reactive arguments

A composable that takes a plain value will not react when the caller’s value changes. Accept a MaybeRefOrGetter and normalise with toValue:

import { toValue, watchEffect, ref } from 'vue';

export function useFetch(url) {           // url: string | Ref<string> | () => string
  const data = ref(null);
  watchEffect(async (onCleanup) => {
    const controller = new AbortController();
    onCleanup(() => controller.abort());
    const res = await fetch(toValue(url), { signal: controller.signal });
    data.value = await res.json();
  });
  return { data };
}

Two things worth pointing out here: toValue inside the effect is what registers the dependency, and onCleanup aborting the previous request is what prevents an out-of-order response overwriting fresher data.

Cleanup and lifecycle

A composable that starts something must stop it. Register the teardown inside the composable so callers cannot forget:

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

Effects created with watch/watchEffect inside setup are disposed automatically when the component unmounts. Effects created outside a component scope are not — wrap them in effectScope() and call scope.stop(), which is exactly what Pinia does for stores.

VueUse

The de facto standard collection, roughly 200 composables covering browser APIs, sensors, state, animation and utilities. It is the “don’t write this yourself” library, and naming it is expected.

Composable Replaces
useLocalStorage / useStorage hand-rolled persistence with a watch
useDebounceFn / useThrottleFn lodash plus manual cleanup
useIntersectionObserver observer setup and teardown
useMediaQuery / useBreakpoints resize listeners
useFetch a fetch wrapper with abort and refetch
onClickOutside document listeners with a ref check
useVModel the props/emit boilerplate for a wrapped input

Two practical points: it is tree-shakeable, so importing five composables does not pull in the rest; and several are SSR-safe by design (useStorage and friends guard window), which matters under Nuxt.

When not to extract one

Extracting logic used in exactly one component is indirection, not reuse. Extract when a second caller appears, or when the logic is genuinely independent of the component’s markup and you want to test it alone — which is the real payoff, since a composable is testable with mount-free unit tests.

Interview angle

  • “What is a composable and how does it differ from a React hook?” - a plain function using Vue’s reactivity APIs to encapsulate stateful logic. The key differences: it runs once rather than every render, has no call-order rules, needs no dependency arrays, and does not suffer stale closures.
  • “Why should a composable return refs rather than a reactive object?” - so callers can destructure. Destructuring a reactive object yields plain values and breaks reactivity; refs survive it.
  • “How do you make a composable react to a changing argument?” - accept a ref or getter and read it with toValue inside the effect, so the dependency is tracked. Taking a plain string captures one value forever.
  • “How do you clean up?” - register onUnmounted or use watch’s cleanup callback inside the composable itself. For effects outside a component, effectScope plus scope.stop().
  • “What is VueUse and would you use it?” - the standard composable collection; yes, for browser-API wrappers, debouncing and storage, because it is tree-shakeable and handles SSR guards you would otherwise get wrong.