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
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:
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:
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:
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 drillsReading this page is the cheap half. These are the exercises that make you use it.
- ComposablesProBuild the useLocalStorage composable this page names — one that actually reads and persists, not just wraps a ref.DrillSolve · MediumDrillSolve · Medium
- Async ComposablesProA useAsyncData composable missing the try/catch/finally every async composable needs for its loading/error state.DrillSolve · MediumDrillSolve · Medium
- Router · Router BasicsuseRoute and useRouter are the composables you'll use most in any real app — this set is the way to meet them.QuizTake · EasyQuizTake · Easy
Read next
Ref vs Reactive: Vue's State Primitives
ref wraps any value behind a .value getter/setter; reactive proxies an object directly. The trade-offs explain why most Vue code reaches for ref by default.
2 min
Pinia: State Management for Vue Apps
A Pinia store is a composable with a fixed shape — state, getters and actions — and understanding it as one demystifies most of what it does.
2 min