codejump

AcademyVue

Composables: Reusable Stateful Logic

A composable is a plain function using ref, computed and watch — Vue's answer to a custom React hook, without the rules-of-hooks constraints.

3 min readUpdated

3 exercises

Waiting at the end of this page.

A composable is a function, not a special construct

Anything that mixes ref, computed or watch and returns state plus the functions that change it is a composable — the pattern is a naming convention (useSomething) over plain JavaScript, not a feature the framework validates:

JavaScript
import { ref, computed } from 'vue'

function useCounter(initial = 0) {
  const count = ref(initial)
  const isPositive = computed(() => count.value > 0)

  function increment() { count.value++ }
  function decrement() { count.value-- }

  return { count, isPositive, increment, decrement }
}

Called from a component's <script setup>, const { count, increment } = useCounter() gives that component instance its own independent state — each call to useCounter() creates fresh refs, the same way calling a factory function twice creates two separate closures.

No rules-of-hooks, because there is no hook mechanism

React's custom hooks are constrained by the rules of hooks — never call one conditionally, never inside a loop — because hooks are matched to their state by call order across renders. A composable has no such mechanism to preserve: it is called once, during setup(), and everything it returns is a ref or a function closed over the composable's own local state. That means a composable genuinely can be called conditionally or built dynamically, because nothing downstream is tracking "the fourth hook call in this component" the way React's fiber does.

Composing composables

The name earns its plural: composables call other composables freely, since they are just functions calling functions:

JavaScript
function useDebouncedSearch(delay = 300) {
  const query = ref('')
  const debouncedQuery = ref('')
  let timer

  watch(query, (value) => {
    clearTimeout(timer)
    timer = setTimeout(() => { debouncedQuery.value = value }, delay)
  })

  return { query, debouncedQuery }
}

function useSearchResults() {
  const { query, debouncedQuery } = useDebouncedSearch()
  const results = ref([])

  watch(debouncedQuery, async (value) => {
    results.value = await fetchResults(value)
  })

  return { query, results }
}

useSearchResults never touches a timer directly — it delegates the debounce concern entirely to useDebouncedSearch and only reacts to the value that comes out the other end. This is the same instinct as extracting a helper function anywhere else in the codebase, just applied to stateful, reactive logic instead of pure computation.

Cleanup still needs a lifecycle hook

A composable that starts an interval, opens a subscription or attaches a listener has to also tear it down, and that requires onUnmounted — the one place a composable does hook into the component it is used from:

JavaScript
import { onUnmounted } from 'vue'

function useInterval(callback, delay) {
  const id = setInterval(callback, delay)
  onUnmounted(() => clearInterval(id))
}

Skipping this is the composable equivalent of a React effect with no cleanup function — invisible on the happy path, and a leak the moment the component using it unmounts while the interval is still running.

What interviewers check

Whether you can build a small composable live — useToggle, useLocalStorage, a debounced ref — and, just as often, whether you remember the onUnmounted cleanup for anything the composable started that outlives a single render.

Now practice it

1 quiz · 2 drills

Reading this page is the cheap half. These are the exercises that make you use it.