If you have ever built a Vue app that talks to an API, you know the pattern: fetch some data, show a loading spinner, handle errors, maybe cache the result, refetch when the user returns, keep old data visible while refreshing, avoid duplicate requests, and then watch everything refetch at the worst moment. You start with "just one request," and two weeks later you are running a small logistics company for asynchronous state.

Vue Query (officially TanStack Query for Vue) is the antidote to that chaos. It treats server data as a first-class thing with its own rules and gives you a consistent way to fetch, cache, sync, and update remote data. The payoff is less glue code, fewer bugs on flaky Wi-Fi, and a UI that feels smart because it keeps itself up to date.

Let’s look at what Vue Query does, why it helps, how it works, and how to use it without turning your app into an abstract art show of hooks and options.

The mental shift: "server state" is not the same as "app state"

Many people assume all state is the same and just lives in different places. In fact, client state and server state are very different. Client state is things like "is the sidebar open?" Server state is things like "what are the latest invoices for this customer?" Server state is owned by the server, can change without your app knowing, can be slow to fetch, can fail, and can be stale the moment you get it.

Before Vue Query, teams often forced server state into global stores and managed everything by hand. That leads to tangled code: fetch logic mixed with UI logic, duplicated loaders, and a "cache" that is really just a variable you hope is still valid.

Vue Query is built around the idea that server state needs special handling:

Once you adopt that mental model, Vue Query stops feeling like an extra library and starts to feel like the missing layer between your UI and your API.

What Vue Query can do (and what it deliberately does not)

Vue Query is great at coordinating remote data. It does not replace your router, your form library, your local UI state store, or your API client. Think of it as a server-state manager with superpowers.

Here’s a practical summary of its core capabilities:

Feature What it means in practice Why you care
Query caching Stores results by a key Fewer network calls, faster UI
Automatic refetching Refreshes data on focus, reconnect, intervals Keeps UI fresh with little work
Request deduping Same query key shares one in-flight request No "double fetch" bugs
Stale-while-revalidate Keep old data while fetching new UI stays stable and responsive
Pagination and infinite loading Built-in patterns for "load more" Less DIY boilerplate
Mutations Handle POST/PUT/DELETE with status tracking Clean write flows
Optimistic updates Update UI immediately, rollback if needed Snappy UX
Devtools Visualize queries and cache Debugging becomes sane

What it does not do: it will not tell you how to structure your backend, it will not automatically normalize data like some GraphQL clients, and it will not remove the need to think about API design. It simply makes the client-side handling of server data much less painful.

Installing Vue Query and wiring it into your Vue app

Vue Query is part of the TanStack Query ecosystem. For Vue 3, you usually install @tanstack/vue-query.

npm install @tanstack/vue-query

Then register the plugin in your main.ts or main.js:

import { createApp } from 'vue'
import { VueQueryPlugin, QueryClient } from '@tanstack/vue-query'
import App from './App.vue'

const app = createApp(App)

const queryClient = new QueryClient({
  defaultOptions: {
    queries: {
      retry: 2,
      refetchOnWindowFocus: true,
    },
  },
})

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

This QueryClient is the brain. It holds the cache, tracks in-flight requests, and coordinates refetch rules. Set sensible defaults here so you do not repeat options on every query.

One myth to clear up: Vue Query is not "just caching." It is a full lifecycle manager for server data, and the QueryClient is how it remembers, shares, and updates that lifecycle across your app.

Your first query: fetching data with a key and a function

A query has two parts:

  1. A query key - what data is this?
  2. A query function - how do I fetch it?

Here is a simple example that fetches a list of todos.

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

type Todo = { id: number; title: string; completed: boolean }

async function fetchTodos(): Promise<Todo[]> {
  const res = await fetch('https://jsonplaceholder.typicode.com/todos?_limit=10')
  if (!res.ok) throw new Error('Failed to fetch todos')
  return res.json()
}

const todosQuery = useQuery({
  queryKey: ['todos'],
  queryFn: fetchTodos,
})
</script>

<template>
  <div>
    <p v-if="todosQuery.isLoading">Loading todos...</p>
    <p v-else-if="todosQuery.isError">Oops: {{ todosQuery.error.message }}</p>

    <ul v-else>
      <li v-for="t in todosQuery.data" :key="t.id">
        <label>
          <input type="checkbox" :checked="t.completed" disabled />
          {{ t.title }}
        </label>
      </li>
    </ul>
  </div>
</template>

Note what you did not write: no ref([]), no try/catch scattered around, no manual loading = true, no caching logic, no "what if two components render at once?" code. Vue Query gives you a consistent state machine: loading, error, success, plus metadata and helpers.

Key detail: the query key is not decoration. It is the identity of the data in the cache. If two components use the same key, they share cached data and network work.

Query keys that scale: parameters, users, and the "cache identity" rule

If your query depends on a variable, include that variable in the key. Otherwise you might fetch user 7 and show it for user 42.

import { useQuery } from '@tanstack/vue-query'
import { computed } from 'vue'

async function fetchUser(id: string) {
  const res = await fetch(`/api/users/${id}`)
  if (!res.ok) throw new Error('Failed to fetch user')
  return res.json()
}

const userId = computed(() => route.params.id as string)

const userQuery = useQuery({
  queryKey: ['user', userId],
  queryFn: () => fetchUser(userId.value),
  enabled: computed(() => !!userId.value),
})

Three important ideas are in that snippet:

A misconception to fix: some people treat query keys like labels. They are not labels. They are the primary key of your cache.

Keeping data fresh without being annoying: staleTime, cacheTime, and refetching

Vue Query has a concept of stale data. Data can be shown immediately from cache but still be considered stale and eligible for refetching. This is how you get fast UIs that also stay current.

Two options are especially useful:

Example:

const todosQuery = useQuery({
  queryKey: ['todos'],
  queryFn: fetchTodos,
  staleTime: 1000 * 30, // 30 seconds fresh
  gcTime: 1000 * 60 * 5, // keep unused cache for 5 minutes
  refetchOnWindowFocus: true,
})

If you set staleTime to 0 (the default), data becomes stale immediately after fetching, so Vue Query may refetch when components mount or when the window refocuses. That is not wrong, but it surprises people who expect caching to mean "never refetch." Caching in Vue Query is more like: "reuse what you have, then update intelligently."

Mutations: changing data, tracking status, and updating what the user sees

Queries are for reads. Mutations are for writes - creating, updating, deleting. A mutation tracks a lifecycle similar to queries: pending, error, success. The key difference is that mutations do not automatically update queries unless you tell Vue Query what to do.

Here is a mutation that adds a todo and then refreshes the list.

<script setup lang="ts">
import { useMutation, useQueryClient } from '@tanstack/vue-query'

type NewTodo = { title: string }

async function createTodo(payload: NewTodo) {
  const res = await fetch('/api/todos', {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify(payload),
  })
  if (!res.ok) throw new Error('Failed to create todo')
  return res.json()
}

const queryClient = useQueryClient()

const createTodoMutation = useMutation({
  mutationFn: createTodo,
  onSuccess: () => {
    // Invalidate tells Vue Query, "your cached todos might be outdated."
    queryClient.invalidateQueries({ queryKey: ['todos'] })
  },
})
</script>

<template>
  <button
    :disabled="createTodoMutation.isPending"
    @click="createTodoMutation.mutate({ title: 'Learn Vue Query' })"
  >
    {{ createTodoMutation.isPending ? 'Adding...' : 'Add todo' }}
  </button>

  <p v-if="createTodoMutation.isError">
    {{ createTodoMutation.error.message }}
  </p>
</template>

Invalidation is the most common strategy: after a write, mark relevant queries as stale so they refetch. It is simple and correct, though sometimes you can do better.

The "wow, that feels fast" trick: optimistic updates (with rollback)

Optimistic updates update the UI as if the server succeeded, then undo the change if the server fails. Users like it because the app feels instant. With Vue Query, optimistic updates are manageable if you follow a simple pattern.

The idea for adding a todo optimistically:

  1. Cancel any in-flight fetches for ['todos'].
  2. Snapshot the current cached todos.
  3. Update the cache to include a temporary todo.
  4. If the server fails, roll back to the snapshot.
  5. After success or failure, refetch to sync.
import { useMutation, useQueryClient } from '@tanstack/vue-query'

const queryClient = useQueryClient()

const createTodoMutation = useMutation({
  mutationFn: createTodo,
  onMutate: async (newTodo) => {
    await queryClient.cancelQueries({ queryKey: ['todos'] })

    const previousTodos = queryClient.getQueryData<any[]>(['todos'])

    queryClient.setQueryData<any[]>(['todos'], (old = []) => [
      ...old,
      { id: crypto.randomUUID(), title: newTodo.title, completed: false, optimistic: true },
    ])

    return { previousTodos }
  },
  onError: (_err, _newTodo, context) => {
    queryClient.setQueryData(['todos'], context?.previousTodos)
  },
  onSettled: () => {
    queryClient.invalidateQueries({ queryKey: ['todos'] })
  },
})

This pattern is simple once you use it: snapshot, apply, rollback, resync. It becomes a real superpower.

A myth to avoid: optimistic updates are not "lying to the user." They are a prediction with a safety net - the rollback and the final refetch.

Pagination and infinite lists without tears

Two common UI patterns are page-based lists and infinite scroll. Vue Query supports both while keeping your cache consistent and your UI calm.

For classic pagination, include the page in the query key:

const page = ref(1)

const usersQuery = useQuery({
  queryKey: ['users', page],
  queryFn: () => fetch(`/api/users?page=${page.value}`).then(r => r.json()),
  keepPreviousData: true,
})

keepPreviousData prevents the UI from flashing empty while page 2 loads. It shows page 1 until page 2 arrives, and you can display a subtle "Updating..." indicator with isFetching.

For infinite loading, use useInfiniteQuery, which organizes pages and gives helpers like fetchNextPage. The details depend on how your API returns next-page info, but the result is fewer custom flags and fewer edge cases.

Debugging and confidence: Devtools and the "isFetching" nuance

Vue Query Devtools lets you inspect queries, their keys, whether they are stale, and what data is cached. This is invaluable when something refetches unexpectedly or a key is wrong.

A subtle but useful distinction: isLoading and isFetching are not the same.

That distinction helps you show a full-screen loader only when you truly have nothing and a gentle "refreshing" indicator when updating.

Common mistakes (and how to look clever by avoiding them)

Common pitfalls:

If you avoid these, Vue Query feels predictable. If you do not, it still works, but it will occasionally prank you, usually on demo day.

A practical path to adopting Vue Query in a real project

You do not need a big rewrite to add Vue Query to an existing app. Migrate feature by feature:

This incremental approach keeps your team comfortable and your codebase stable.

Closing: the moment your app starts behaving like it has common sense

Vue Query gives your Vue app a quiet competence: data appears quickly, updates reliably, and loading states feel intentional instead of improvised. You stop writing the same fetch-and-flags routine in every component. You start thinking in clear patterns: identify data with keys, fetch with functions, update with mutations, and sync with invalidation or cache updates.

If you build anything beyond a tiny demo, this tool pays back daily. Start small, get one query working, and watch the "server-state logistics company" inside your codebase downsize. The best part is your users will not know why the app feels smoother - they will just assume you are very good at your job, which is a perfectly acceptable outcome.

Web Development & Design

Vue Query (TanStack Query for Vue): A Hands-On Guide to Fetching, Caching, and Mutating Server Data in Vue 3

December 24, 2025

What you will learn in this nib : You will learn how to use Vue Query in Vue 3 to fetch, cache, deduplicate, and refetch server data reliably, manage loading and error states, perform mutations and optimistic updates, handle pagination and infinite lists, and wire a QueryClient into your app for less boilerplate and a smoother user experience.

  • Lesson
  • Quiz
nib