skip to content

How would you write a Vue 3 useFetch(url) composable that accepts a string, a ref or a getter and re-fetches when the URL changes?

level: middleimportance: should knowfreq 55%

answer

  1. normalise the input once, but where?
  2. read inside a reactive effect
  3. toValue in watchEffect or watch source
  4. plain string tracks nothing
  5. abort stale requests on cleanup

basics

~20 s

Type url as MaybeRefOrGetter<string>, call toValue(url) inside a watchEffect (or as a watch source) so the ref or getter is tracked, fetch into data and error refs, abort stale requests in cleanup, and return the refs.

solid answer

~40 s

Declare `url` as `MaybeRefOrGetter<string>`, create `data`, `error` and `loading` refs, and start a `watchEffect()` that calls `toValue(url)` *inside* the effect before fetching. `toValue()` (Vue 3.3+) unwraps a ref or calls a getter, so reading it inside the effect subscribes to exactly what the caller passed: a ref's `.value`, or whatever the getter reads, such as `props.id`. A plain string tracks nothing, so the effect runs once. To stop an old response overwriting a newer one, create an `AbortController` per run and abort it from `onWatcherCleanup()` (3.5+), which also fires when the watcher is stopped on unmount. Return the refs as a plain object so the caller can destructure them.

code

ts · 25 lines
ts
import { shallowRef, ref, watchEffect, toValue, onWatcherCleanup, type MaybeRefOrGetter } from 'vue'

export function useFetch<T>(url: MaybeRefOrGetter<string>) {
  const data = shallowRef<T | null>(null)
  const error = shallowRef<unknown>(null)
  const loading = ref(false)

  watchEffect(() => {
    const target = toValue(url) // read inside the effect, so a ref or getter is tracked
    const controller = new AbortController()
    onWatcherCleanup(() => controller.abort()) // Vue 3.5+: runs before the next run and on stop

    data.value = null
    error.value = null
    loading.value = true

    fetch(target, { signal: controller.signal })
      .then((res) => res.json())
      .then((json: T) => { data.value = json })
      .catch((err) => { if (!controller.signal.aborted) error.value = err })
      .finally(() => { if (!controller.signal.aborted) loading.value = false })
  })

  return { data, error, loading }
}

go deeper

for a junior

Recall that the URL must be read inside a watcher or watchEffect for the composable to react to a changing ref or prop.

for a middle

Explain what toValue normalises, why it must run inside the effect, and how a string, a ref and a getter each behave.

for a senior

Demonstrate race handling: an AbortController per run aborted from watcher cleanup, and state resets so stale data never flashes.

for a principal

Discuss when a hand-written fetch composable should give way to a shared data-fetching layer with caching, deduplication and consistent error handling.

## The goal A `useFetch(url)` composable hides the loading, data and error bookkeeping that every request needs. It should accept three kinds of input: a plain string (fetch once), a ref (re-fetch when `.value` changes) and a getter such as `` () => `/api/posts/${props.id}` `` (re-fetch when anything the getter reads changes). Callers should not have to care which one they pass. ## Normalising the input with toValue() `toValue()`, added in Vue 3.3, turns any of those inputs into a plain value: a ref yields its `.value`, a function is called and its result returned, and anything else passes through unchanged. The TypeScript type for such a parameter is `MaybeRefOrGetter<string>`, exported from `vue`. Normalising is only half the job. The value must be read **inside a reactive effect**, otherwise nothing subscribes to it. ## Where toValue() has to run 1. Create the state refs: `data`, `error`, `loading`. 2. Start a `watchEffect()`, or a `watch()` with `immediate: true`. 3. Inside that effect, call `toValue(url)` first. Reading a ref's `.value`, or running a getter that reads props, during the effect's synchronous execution is what registers the dependency. 4. Start the request with the normalised URL. 5. Return the refs. If the composable calls `toValue(url)` once at the top and hands the resulting string to the effect, the effect has read no reactive source. It runs once and never again, whatever happens to the ref. That single misplaced line is the most common bug in hand-written fetch composables. The `watch()` form makes the source explicit: `watch(() => toValue(url), fetchData, { immediate: true })`. Both forms work; `watch()` is clearer when the fetching code reads other reactive state that should *not* trigger a new request. ## What each input does at runtime | Caller passes | Dependency tracked | Behaviour | |---|---|---| | `'/api/posts'` | none | the effect runs once: one request | | `url`, a ref | `url.value` | re-fetches whenever the ref is reassigned | | `` () => `/api/posts/${props.id}` `` | `props.id` | re-fetches when the prop changes | Note the trap on the caller's side: `` useFetch(`/api/posts/${props.id}`) `` evaluates the template literal once and passes a string, so it never re-fetches. The caller must pass the getter. ## Races and cleanup Once the URL can change, two requests can be in flight at once, and the older one may resolve last and overwrite fresh data. In Vue 3.5+ the effect can register `onWatcherCleanup()`, which runs before the effect's next run and when the watcher is stopped. Creating an `AbortController` per run and aborting it in that cleanup cancels the stale request. Two details matter: - `onWatcherCleanup()` must be called during the effect's synchronous execution, not after an `await`. - The older form, still available, is the `onCleanup` argument passed to the effect function (`watchEffect((onCleanup) => ...)`) and to the `watch()` callback. A watcher created synchronously during setup is bound to the component and is stopped on unmount, and stopping it runs the cleanup, so an in-flight request is also aborted when the component goes away. ## Design points interviewers probe - **Return refs, not a reactive object**, so `const { data, error } = useFetch(...)` keeps reactivity. - **Reset state at the start of each run**, so the template does not show the previous post while the next one loads. - **Only synchronous reads are tracked**: in `watchEffect`, anything read after an `await` is invisible to the tracker, another reason to resolve the URL first. - **Prefer `shallowRef` for replaced-wholesale payloads**, so a large JSON response is not made deeply reactive. - **Keep the call site synchronous** in `<script setup>`, so the composable's watcher binds to the calling component. A well-written `useFetch` therefore has one reactive input, a handful of output refs, a single effect that owns the request, and a cleanup that makes the latest URL always win.

  • Why might you write watch(() => toValue(url), fetchData, { immediate: true }) instead of watchEffect here?
    `watch()` separates the source from the side effect: only the URL is tracked, so reactive state read inside `fetchData`, such as a page-size ref, cannot trigger extra requests. It also hands the callback the new and old values. `watchEffect()` is terser but depends on everything its body reads synchronously.
  • What goes wrong if the caller writes useFetch(`/api/posts/${props.id}`)?
    The template literal is evaluated once, at the call, into a plain string. The composable receives a non-reactive value, its effect tracks nothing, and it never re-fetches when `props.id` changes. The caller must pass a getter, `() => `/api/posts/${props.id}``, or a ref or computed.

saying these in an interview costs you the question

  • Calling toValue(url) once at the top of the composable keeps the URL reactive.
  • Passing a template-literal string built from props re-fetches when the prop changes.
  • The newest request always resolves last, so fetch races need no handling.
  • watchEffect also tracks refs that are read after an await inside it.
  • onWatcherCleanup can be registered after the await of the fetch call.