TanStack Query for Vue

useQuery from @tanstack/vue-query 5: caching, retries and background refetch, and what the query key is for.

Syntaxconst { data, isPending, isError, error, refetch } = useQuery({ queryKey: ['user', userId], queryFn })

Caching, deduplicating, retrying and refetching are the same work in every application, so a library does it. @tanstack/vue-query, major version 5, is that library for Vue. Install it with npm i @tanstack/vue-query and register it once with app.use(VueQueryPlugin); without the plugin, every useQuery call throws.

useQuery

useQuery({ queryKey, queryFn }) returns refs: data, error, isPending, isFetching, isError, isSuccess, and a refetch function. Templates unwrap them; in script you still read data.value. In version 5 the first-load flag is isPending — version 4 called that same flag isLoading, and version 5 reused the name isLoading for isPending && isFetching, so older material using isLoading for the first load means today's isPending.

The query key

The key is an array naming the cache entry. Every value the queryFn reads belongs in it. A key containing a ref is reactive: change the ref and the query refetches under the new key, while the old entry stays cached for when you go back.

Freshness

staleTime, 0 by default, is how long a result counts as fresh. While it is fresh, another component asking for the same key gets it with no request. After that the cached data still renders and a background refetch replaces it.

Two things that bite

The queryFn receives a context with queryKey, client, signal and meta; pass signal to fetch and cancellation is handled. And fetch still does not throw on a 404, so throw yourself or the error body is cached as data.

Example

Example · vue
<script setup lang="ts">
import { computed } from 'vue'
import { useQuery } from '@tanstack/vue-query'

interface User {
  id: number
  name: string
  email: string
}

const props = defineProps<{ userId: number }>()
const userId = computed(() => props.userId)

const { data: user, error, isPending, isError, isFetching, refetch } = useQuery({
  queryKey: ['user', userId],
  queryFn: async ({ signal }) => {
    const res = await fetch(`/api/users/${userId.value}`, { signal })
    if (!res.ok) throw new Error(`HTTP ${res.status}`)
    return (await res.json()) as User
  },
  staleTime: 30_000,
})
</script>

<template>
  <p v-if="isPending">Loading the profile</p>
  <p v-else-if="isError">Could not load the profile: {{ error?.message }}</p>
  <div v-else-if="user">
    <h2>{{ user.name }}</h2>
    <p>{{ user.email }}</p>
    <p v-if="isFetching">Refreshing</p>
    <button type="button" @click="refetch()">Refresh</button>
  </div>
</template>

<style scoped>
button {
  cursor: pointer;
}
</style>

When to use it

  • Show a list and its detail view without refetching the list every time the user goes back.
  • Dedupe the same request fired by three components that mounted together.
  • Retry a flaky endpoint automatically instead of writing the retry loop yourself.
  • Keep a dashboard current with a background refetch while the old numbers stay on screen.

More examples

Registering the plugin

One place for the defaults every query inherits, which keeps the options out of each component. staleTime: 60_000 means a result is reused for a minute before a background refetch; retry: 2 means a failing query is attempted three times in total before isError flips; refetchOnWindowFocus: false turns off the refetch that otherwise fires when the tab regains focus, which is the setting people most often find surprising in development. Passing your own QueryClient also lets tests and server-side rendering build a fresh one per run.

Example · ts
// src/main.ts
import { createApp } from 'vue'
import { VueQueryPlugin, QueryClient } from '@tanstack/vue-query'
import App from './App.vue'
import router from './router'

const queryClient = new QueryClient({
  defaultOptions: {
    queries: {
      staleTime: 60_000,
      retry: 2,
      refetchOnWindowFocus: false,
    },
  },
})

createApp(App).use(router).use(VueQueryPlugin, { queryClient }).mount('#app')

A key factory and a dependent query

enabled takes a ref, so the query waits until there is a customer to ask about instead of firing a request to /api/customers/null/invoices. The key has three segments: a resource, a relation and the id. Structuring keys this way means an invalidation can target one customer with ['invoices', 'byCustomer', 7] or every invoice list with ['invoices'], because a shorter key matches every longer key that starts with it.

Example · vue
<script setup lang="ts">
import { computed } from 'vue'
import { useQuery } from '@tanstack/vue-query'

interface Invoice {
  id: number
  total: number
}

const props = defineProps<{ customerId: number | null }>()

const customerId = computed(() => props.customerId)
const enabled = computed(() => customerId.value !== null)

const { data: invoices, isPending, isError } = useQuery({
  queryKey: ['invoices', 'byCustomer', customerId],
  queryFn: async ({ signal }) => {
    const res = await fetch(`/api/customers/${customerId.value}/invoices`, { signal })
    if (!res.ok) throw new Error(`HTTP ${res.status}`)
    return (await res.json()) as Invoice[]
  },
  enabled,
})
</script>

<template>
  <p v-if="!enabled">Pick a customer to see their invoices.</p>
  <p v-else-if="isPending">Loading invoices</p>
  <p v-else-if="isError">Could not load the invoices.</p>
  <ul v-else>
    <li v-for="invoice in invoices" :key="invoice.id">{{ invoice.total }}</li>
  </ul>
</template>

<style scoped>
li {
  font-variant-numeric: tabular-nums;
}
</style>

Keys in one module

Query keys are compared by value, so a typo in an array literal creates a second cache entry that nothing ever invalidates. Building them in one module removes that class of bug and gives the mutations in the next lesson something exact to invalidate. as const keeps the literal types, so the type checker catches userKeys.detail('7'). Keep one of these files per resource next to the composables that use it.

Example · ts
// src/queries/keys.ts
export const userKeys = {
  all: ['users'] as const,
  list: (page: number) => ['users', 'list', page] as const,
  detail: (id: number) => ['users', 'detail', id] as const,
}

// userKeys.detail(7).join('/')
// Output: users/detail/7

// userKeys.list(2).join('/')
// Output: users/list/2

// userKeys.all.join('/')
// Output: users

Discussion

  • Be the first to comment on this lesson.