skip to content

Composables in Isolation

Calling a reactivity-only composable directly, or mounting a host component when it needs lifecycle hooks or inject. Interviewers probe why onMounted or inject warns outside setup.

part ofVue.jsoverview, primer and where to startread it →
on this pageshow

explore

questions

4

When testing a Vue 3 composable, when can the test simply call it, and when does it need a host component?

level: juniorimportance: must knowfreq 50%

answer

  1. which APIs need a current instance
  2. reactivity-only versus instance-bound
  3. lifecycle hooks and inject
  4. read the dev warning it logs

basics

~20 s

A composable that only uses reactivity APIs such as ref, computed and watch can be called directly in a test. One that registers lifecycle hooks like onMounted, or calls inject, needs a component instance, so it must run inside a host component's setup.

solid answer

~40 s

The Vue docs split composables into two groups. If a composable uses only **reactivity APIs** (`ref`, `reactive`, `computed`, `watch`), call it in the test and assert on the refs it returns; a `useFetch`-style composable usually qualifies. If it uses **lifecycle hooks** (`onMounted`, `onUnmounted` and friends) or **provide/inject**, it depends on a component instance, which only exists while a component's `setup` runs. Called directly, `onMounted` logs a dev warning that there is no active component instance and the callback **never runs**, and `inject` warns that it can only be used inside `setup()` and returns `undefined`. A `useMouse`-style composable that attaches a listener in `onMounted` therefore needs a host: a tiny component, mounted just for the test, whose `setup` calls the composable.

code

ts · 8 lines
ts
import { ref, watch, type Ref } from 'vue'

// reactivity only: can be called directly in a test
export function useSearchUrl(term: Ref<string>) {
  const url = ref('')
  watch(term, (t) => { url.value = `/search?q=${encodeURIComponent(t)}` }, { immediate: true })
  return { url }
}

go deeper

for a junior

Recall the split: reactivity-only composables can be called directly, while lifecycle hooks and inject need a component instance, so they run inside a host component.

for a middle

Explain why: hooks attach to the current instance set during setup, and inject reads its provides chain, so outside setup Vue warns and drops or returns undefined.

for a senior

Catch false-green tests where a direct call silently skipped a hook, and decide per code path whether a direct call, a host or runWithContext is the lightest honest harness.

for a principal

Push composable design toward testable seams, such as keeping pure logic separate from hook wiring, so most behaviour can be verified without mounting anything.

## Two kinds of composable A **composable** is a function, named `useSomething` by convention, that packages stateful logic with Vue's Composition API. From a testing point of view, what matters is not what the composable does but **which Vue APIs it calls**, because some of them only work while a component is being set up. The official Vue testing guide draws the line explicitly. A composable depends on a **host component instance** when it uses: - **lifecycle hooks** such as `onMounted`, `onUpdated`, `onUnmounted` or `onBeforeUnmount`; - **provide / inject**. Everything else in the reactivity layer, such as `ref`, `reactive`, `computed`, `watch`, `watchEffect`, `toRef` and `shallowRef`, works without any component. ## Why hooks and inject need an instance When Vue runs a component's `setup()`, it sets that component as the **current instance**. Hook registration functions like `onMounted(fn)` do nothing more than attach `fn` to the current instance's list of mounted hooks. If there is no current instance, there is nothing to attach to. In development Vue logs: > onMounted is called when there is no active component instance to be associated with. Lifecycle injection APIs can only be used during execution of setup(). The hook is then simply dropped. It does not run later, and it does not run immediately. `inject(key)` has the same dependency: it looks the key up in the current instance's provides chain, or, since Vue 3.3, in an app context set by `app.runWithContext()`. With neither, it warns `inject() can only be used inside setup() or functional components.` and returns `undefined`. ## Deciding in practice | The composable uses | Test approach | Example | |---|---|---| | only `ref`, `computed`, `watch`, `watchEffect` | call it directly, assert on returned refs | a `useFetch(url)` that watches a URL ref | | `onMounted` / `onUnmounted` | run it inside a mounted host component | a `useMouse()` that adds a `mousemove` listener on mount | | `inject` only | host component, or `app.runWithContext()` | a `useApi()` that injects a client | | hooks and inject | host component with provided values | a composable that injects a logger and cleans up on unmount | A quick way to classify a composable you did not write is to call it directly once and read the console. A hook or injection warning names exactly what is missing. ## The direct call For a reactivity-only composable, the test is a plain function test: 1. Call the composable, passing refs for any reactive inputs. 2. Assert on the returned refs by reading `.value`. 3. Change an input ref and assert again. Watcher callbacks default to the pre-flush queue, so they run asynchronously even without a component; the test must await Vue's next tick before asserting on their effects. One extra concern applies: `watch` and `watchEffect` created outside any component are not stopped automatically, because nothing owns them. Tests that care about teardown run the call inside an `effectScope()` and stop the scope afterwards. ## The host component For a composable that needs an instance, the test creates a component whose only job is to call it: - with Vue Test Utils, `mount()` a small component defined in the test, whose `setup` calls the composable and returns what the test wants to read; - without it, a `withSetup` helper built on `createApp` does the same, as shown in the Vue testing guide; - mounting runs `onMounted` hooks before `mount` returns, and unmounting the host (`wrapper.unmount()` or `app.unmount()`) runs `onUnmounted`, which is how you test cleanup. ## A common misreading A test that calls `useMouse()` directly often **looks** like it passes: the returned refs exist and start at `0`, and an assertion that they are `0` succeeds. The listener was never attached, though, so the composable's real behaviour went untested. The warning in the console is the only clue, which is why the classification step matters. ## Making the warnings count Because both failure modes are warnings rather than errors, a suite can quietly accumulate tests that exercise nothing. Two habits help: - treat unexpected Vue warnings as test failures in the shared test setup, so a direct call to a hook-based composable fails immediately instead of passing on initial values; - when a composable changes from reactivity-only to hook-based, for example because someone adds an `onUnmounted` cleanup, revisit its tests; the old direct calls keep passing while the new cleanup path is never exercised. Both habits turn a silent gap into a visible red test, which is where it belongs.

  • A composable calls `onMounted` only inside an `if (options.autoStart)` branch. Does that change how you test it?
    Yes, per branch. With `autoStart` false, no hook is registered and a direct call is fine. With it true, the hook needs a component instance, so that path must run inside a host. Classify by what each tested path actually calls, not by the imports at the top of the file.
  • Why does a direct call to a hook-based composable not fail the test outright?
    Because Vue only warns in development and drops the hook; nothing throws. The returned refs still exist with their initial values, so assertions on initial state pass. Only assertions on behaviour that depends on the hook fail, or, worse, nobody writes them. Treating those warnings as failures in the test setup is a cheap safeguard.

saying these in an interview costs you the question

  • Every composable needs to be mounted in a component before it can be tested.
  • Calling onMounted outside a component runs the callback immediately instead.
  • A composable that uses watch cannot run outside a component at all.
  • inject outside setup throws, so a missing host always fails the test loudly.
  • If the returned refs exist, the composable's hooks must have run.
open as a page

How do you build a host for testing a Vue 3 `useMouse()` composable that adds a listener in `onMounted` and removes it in `onUnmounted`?

level: middleimportance: should knowfreq 40%

basics

~20 s

Create a throwaway component whose setup calls useMouse and hands its refs back, mount it so onMounted attaches the listener, dispatch a mousemove on window and assert the refs, then unmount it and check the listener is gone.

open as a page

A Vue 3 `useFetch` composable watches a module-level `baseUrl` ref; tests call it directly, and later tests record extra fetches. Why, and how does `effectScope()` fix it?

level: seniorimportance: should knowfreq 30%

basics

~20 s

Watchers created by a direct call belong to no component, so nothing stops them; when a later test changes the shared baseUrl, old watchers still fire and fetch. Run each call inside effectScope().run() and call scope.stop() after the test.

open as a page

In Vue 3.3+, when can `app.runWithContext()` replace a host component for testing a composable, and when can it not?

level: middleimportance: nice to knowfreq 25%

basics

~20 s

app.runWithContext() lends an app's provides to inject() during a synchronous callback, so an inject-only composable can be tested on an app that is never mounted. It supplies no component instance, so composables with lifecycle hooks still need a host component.

open as a page