12 — Fetching Data & Async

useFetch — Production Race Condition Handler

useFetch.ts
import { ref, watchEffect, toValue, type MaybeRefOrGetter, type Ref } from 'vue'

interface FetchOptions {
  immediate?: boolean
  refetch?: boolean  // re-fetch when source changes
  transform?: (data: any) => any
}

// ── Race condition prevention via AbortController ──
// Every time the URL changes, the previous fetch is aborted.
// Only the latest request's response is committed to state.
export function useFetch<T>(
  url: MaybeRefOrGetter<string>,
  options: FetchOptions = {}
) {
  const data: Ref<T | null> = ref(null)
  const error: Ref<Error | null> = ref(null)
  const loading = ref(false)
  const statusCode = ref<number | null>(null)

  let abortController: AbortController | null = null

  async function execute() {
    const targetUrl = toValue(url)
    if (!targetUrl) return

    // ── Abort any in-flight request before starting a new one ──
    abortController?.abort()
    abortController = new AbortController()

    loading.value = true
    error.value = null

    try {
      const res = await fetch(targetUrl, { signal: abortController.signal })
      statusCode.value = res.status
      if (!res.ok) throw new Error(`HTTP ${res.status}: ${res.statusText}`)
      let json = await res.json()
      if (options.transform) json = options.transform(json)
      data.value = json as T
    } catch (e) {
      // ── AbortError is expected — don't set it as an error ──
      if (e instanceof DOMException && e.name === 'AbortError') return
      error.value = e as Error
    } finally {
      loading.value = false
    }
  }

  if (options.refetch !== false) {
    // ── watchEffect auto-tracks toValue(url) — re-fetches on URL change ──
    watchEffect(execute)
  } else if (options.immediate) {
    execute()  // one-time fetch, no re-fetch on change
  }

  return { data, error, loading, statusCode, refresh: execute }
}

Request Deduplication — Coalescing Concurrent Fetches

useDedupFetch.ts
import { ref, type Ref } from 'vue'

// ── Deduplication: if a request to the same URL is in flight,
//    reuse its promise instead of firing a second request. ──
// Critical for components that mount simultaneously and fetch the same data.

const cache = new Map<string, Promise<any>>()

export function useDedupFetch<T>(url: string): {
  data: Ref<T | null>
  loading: Ref<boolean>
  error: Ref<Error | null>
} {
  const data = ref<T | null>(null) as Ref<T | null>
  const loading = ref(true)
  const error = ref<Error | null>(null)

  async function load() {
    // ── Check cache: is a request for this URL already in flight? ──
    if (cache.has(url)) {
      try {
        data.value = await cache.get(url)!
        return
      } catch (e) {
        error.value = e as Error
        return
      } finally {
        loading.value = false
      }
    }

    // ── Start a new request and cache the PROMISE (not the result) ──
    const promise = fetch(url).then(res => {
      if (!res.ok) throw new Error(`HTTP ${res.status}`)
      return res.json()
    })

    cache.set(url, promise)  // other concurrent callers will await this same promise

    try {
      data.value = await promise
    } catch (e) {
      error.value = e as Error
    } finally {
      loading.value = false
      cache.delete(url)  // clear cache entry after resolution
    }
  }

  load()
  return { data, loading, error }
}

SWR (Stale-While-Revalidate) Pattern

useSWR.ts
import { ref, watchEffect, toValue, type MaybeRefOrGetter, type Ref } from 'vue'

// ── SWR: show cached (stale) data immediately, revalidate in background ──
// Provides instant UI render from cache while fetching fresh data.

const cache = new Map<string, { data: any; timestamp: number }>()

export function useSWR<T>(
  key: MaybeRefOrGetter<string>,
  fetcher: (url: string) => Promise<T>,
  ttl = 60_000  // cache validity: 60 seconds
): { data: Ref<T | null>; loading: Ref<boolean>; error: Ref<Error | null> } {
  const data: Ref<T | null> = ref(null) as Ref<T | null>
  const loading = ref(true)
  const error = ref<Error | null>(null)

  watchEffect(async () => {
    const url = toValue(key)
    if (!url) return

    const cached = cache.get(url)

    // ── Phase 1: serve stale data immediately (instant render) ──
    if (cached) {
      data.value = cached.data
      loading.value = false

      // If cache is fresh, no revalidation needed
      if (Date.now() - cached.timestamp < ttl) return
    }

    // ── Phase 2: revalidate in background (fresh fetch) ──
    try {
      const fresh = await fetcher(url)
      data.value = fresh
      cache.set(url, { data: fresh, timestamp: Date.now() })
    } catch (e) {
      // If we have stale data, keep it — don't overwrite with error
      if (!cached) error.value = e as Error
    } finally {
      loading.value = false
    }
  })

  return { data, loading, error }
}

Pagination — Cursor-Based with Accumulated Results

usePaginatedFetch.ts
import { ref, computed, type Ref } from 'vue'

interface PageResult<T> {
  items: T[]
  nextCursor: string | null
  hasMore: boolean
}

// ── Cursor pagination: appends new pages to existing results ──
// Better than offset pagination: stable under inserts/deletes.
export function usePaginatedFetch<T>(
  baseUrl: string,
  pageSize = 20
) {
  const items: Ref<T[]> = ref([]) as Ref<T[]>
  const cursor = ref<string | null>(null)
  const loading = ref(false)
  const hasMore = ref(true)
  const error = ref<Error | null>(null)

  // ── Computed: total count is derived from items array length ──
  const total = computed(() => items.value.length)

  async function loadNext() {
    if (loading.value || !hasMore.value) return

    loading.value = true
    error.value = null

    try {
      const params = new URLSearchParams({ limit: String(pageSize) })
      if (cursor.value) params.set('cursor', cursor.value)

      const res = await fetch(`${baseUrl}?${params}`)
      if (!res.ok) throw new Error(`HTTP ${res.status}`)

      const page: PageResult<T> = await res.json()

      // ── Append, don't replace — preserves previously loaded items ──
      items.value.push(...page.items)
      cursor.value = page.nextCursor
      hasMore.value = page.hasMore
    } catch (e) {
      error.value = e as Error
    } finally {
      loading.value = false
    }
  }

  function reset() {
    items.value = []
    cursor.value = null
    hasMore.value = true
    error.value = null
  }

  return { items, loading, hasMore, total, error, loadNext, reset }
}

Optimistic Updates — Rollback on Failure

optimistic-update.ts
import { ref, type Ref } from 'vue'

// ── Optimistic update: update UI before server confirms ──
// Rollback on failure — user sees instant response, no waiting.
export function useOptimisticUpdate<T extends { id: string }>(
  items: Ref<T[]>,
  updateFn: (item: T) => Promise<T>
) {
  const error: Ref<string | null> = ref(null)

  async function update(item: T, patch: Partial<T>) {
    error.value = null

    // ── Save original for potential rollback ──
    const original = items.value.find(i => i.id === item.id)
    if (!original) return

    // ── Phase 1: apply patch optimistically (instant UI) ──
    const optimistic = { ...original, ...patch }
    items.value = items.value.map(i => i.id === item.id ? optimistic : i)

    try {
      // ── Phase 2: send to server, wait for confirmation ──
      const confirmed = await updateFn(optimistic)
      // Replace with server-confirmed version (may differ from optimistic)
      items.value = items.value.map(i => i.id === confirmed.id ? confirmed : i)
    } catch (e) {
      // ── Phase 3: rollback on failure ──
      items.value = items.value.map(i => i.id === original.id ? original : i)
      error.value = (e as Error).message
    }
  }

  return { update, error }
}

// ── Usage: ──────────────────────────────────────────────
// const { update, error } = useOptimisticUpdate(todos, api.updateTodo)
// await update(todo, { completed: true })  // UI flips instantly, rolls back on error

Suspense — Async Component Orchestration

SuspensePattern.vue
<script setup>
import { defineAsyncComponent, Suspense, ref, onErrorCaptured } from 'vue'

// ── Suspense: declarative async component loading ──
// Parent shows fallback while child's async setup() resolves.
const AsyncDashboard = defineAsyncComponent(() => import('./Dashboard.vue'))

const error = ref(null)

// ── onErrorCaptured: catches errors from async setup in children ──
onErrorCaptured((err) => {
  error.value = err
  return false  // stop propagation
})
</script>

<template>
  <!-- ── Suspense boundary: fallback slot while async children load ── -->
  <Suspense>
    <template #default>
      <AsyncDashboard />
    </template>
    <template #fallback>
      <div class="loading-skeleton">Loading dashboard…</div>
    </template>
  </Suspense>

  <!-- ── Error state (Suspense doesn't handle errors, parent does) ── -->
  <div v-if="error" class="error">
    Failed to load: {{ error.message }}
    <button @click="error = null">Retry</button>
  </div>
</template>
DashboardChild.vue
<script setup>
import { ref } from 'vue'

// ── Top-level await makes this component async ──
// Must be inside a <Suspense> boundary or it throws.
const data = ref(null)

// Suspense waits for this promise to resolve before rendering the component.
const res = await fetch('/api/dashboard-data')
data.value = await res.json()
</script>

<template>
  <div>{{ data }}</div>
</template>

💡 Tips & Tricks

tips.ts
// ── 1. Polling: setInterval inside watch + cleanup ──
watchEffect((onCleanup) => {
  const id = setInterval(() => fetchData(), 5000)
  onCleanup(() => clearInterval(id))
})

// ── 2. Request cancellation on unmount: AbortController + onScopeDispose ──
const controller = new AbortController()
fetch(url, { signal: controller.signal })
onScopeDispose(() => controller.abort())  // cancels pending fetch on unmount

// ── 3. Exponential backoff retry ──
async function fetchWithRetry(url: string, maxRetries = 3) {
  for (let i = 0; i < maxRetries; i++) {
    try { return await fetch(url) }
    catch (e) {
      if (i === maxRetries - 1) throw e
      await new Promise(r => setTimeout(r, 2 ** i * 1000))  // 1s, 2s, 4s
    }
  }
}

// ── 4. Prefetch on hover ──
function prefetch() {
  const link = document.createElement('link')
  link.rel = 'prefetch'
  link.href = '/api/next-page-data'
  document.head.appendChild(link)
}
// <RouterLink @mouseover="prefetch" to="/next">Next</RouterLink>

⚠️ Edge Cases & Gotchas

edge-cases.ts
// ── 1. Stale closure in async callbacks ──
// watchEffect(() => { fetchData().then(data => items.value = data) })
// If items is refactored, the closure captures the old reference.
// Fix: use refs, not local variables, for values that survive async.

// ── 2. Race condition without AbortController ──
// Fast typing → fetch("a") starts → fetch("ab") starts
// fetch("ab") resolves first, fetch("a") resolves last → stale data overwrites.
// Always abort previous request on new trigger.

// ── 3. Suspense doesn't retry on error ──
// If async setup throws, Suspense shows nothing (error must be caught by parent).
// Use onErrorCaptured + v-if error state to show retry button.

// ── 4. Pagination reset on URL change ──
// If the base URL changes (filter applied), cursor must reset to null.
// Otherwise, the cursor from the old query is sent to the new endpoint.

// ── 5. Optimistic update without rollback = permanent inconsistency ──
// If the server fails and you don't rollback, UI shows state the server rejected.
// Always save original state before optimistic mutation.

// ── 6. JSON parse error isn't a network error ──
// fetch() resolves even on 404/500 — check res.ok before parsing.
// res.json() throws on non-JSON body (HTML error page) — wrap in try/catch.

🧠 Spot the Bug

A search component shows results from a previous query after the user types a new one.

AsyncBug.ts
import { ref, watch } from 'vue'

const query = ref('')
const results = ref([])

watch(query, async (q) => {
  const res = await fetch(`/api/search?q=${q}`)
  results.value = await res.json()  // ← stale response can overwrite fresh data
})
Answer

No abort/cancellation — if fetch("a") resolves after fetch("ab"), the stale "a" results overwrite the correct "ab" results.

Fix — abort the previous request on each new query:

AsyncFixed.ts
import { ref, watch } from 'vue'

const query = ref('')
const results = ref([])

watch(query, async (q, _old, onCleanup) => {
  const controller = new AbortController()
  onCleanup(() => controller.abort())  // cancel previous fetch

  try {
    const res = await fetch(`/api/search?q=${q}`, { signal: controller.signal })
    results.value = await res.json()
  } catch (e) {
    if (e.name === 'AbortError') return  // expected — stale request canceled
    throw e
  }
})

The lesson: async watchers must register cleanup to cancel stale work. Without it, out-of-order network responses cause data races — the last-resolved (not the last-requested) response wins.