TanStack Query for Vue provides composables for fetching, caching, and updating asynchronous data in your Vue applications. It supports both Vue 2 (with @vue/composition-api) and Vue 3.
Installation
npm install @tanstack/vue-query
# or
pnpm add @tanstack/vue-query
# or
yarn add @tanstack/vue-query
For Vue 2, you also need to install @vue/composition-api:npm install @vue/composition-api
Setup
Install the Vue Query plugin in your app:
import { VueQueryPlugin } from '@tanstack/vue-query'
import { createApp } from 'vue'
import App from './App.vue'
const app = createApp(App)
app.use(VueQueryPlugin)
app.mount('#app')
Plugin Options
import { QueryClient } from '@tanstack/vue-query'
const queryClient = new QueryClient({
defaultOptions: {
queries: {
staleTime: 1000 * 60 * 5, // 5 minutes
},
},
})
app.use(VueQueryPlugin, {
queryClient,
enableDevtoolsV6Plugin: true, // Enable Vue DevTools integration
})
Core Composables
useQuery
Fetch and cache data with the useQuery composable:
<script setup>
import { useQuery } from '@tanstack/vue-query'
const { data, isLoading, error } = useQuery({
queryKey: ['todos'],
queryFn: async () => {
const res = await fetch('/api/todos')
return res.json()
},
})
</script>
<template>
<div>
<div v-if="isLoading">Loading...</div>
<div v-else-if="error">Error: {{ error.message }}</div>
<ul v-else>
<li v-for="todo in data" :key="todo.id">
{{ todo.title }}
</li>
</ul>
</div>
</template>
Reactive Query Keys
Vue Query fully supports Vue’s reactivity system. Use ref or computed for dynamic queries:
<script setup>
import { ref, computed } from 'vue'
import { useQuery } from '@tanstack/vue-query'
const todoId = ref(1)
const { data } = useQuery({
queryKey: ['todo', todoId],
queryFn: async () => {
const res = await fetch(`/api/todos/${todoId.value}`)
return res.json()
},
})
// Or use computed
const queryKey = computed(() => ['todo', todoId.value])
</script>
<template>
<div>
<button @click="todoId++">Next Todo</button>
<div v-if="data">{{ data.title }}</div>
</div>
</template>
When using refs in query options, Vue Query automatically unwraps them. You can pass todoId directly instead of todoId.value.
useMutation
Perform side effects with mutations:
<script setup>
import { useMutation, useQueryClient } from '@tanstack/vue-query'
const queryClient = useQueryClient()
const { mutate, isPending } = useMutation({
mutationFn: async (newTodo) => {
const res = await fetch('/api/todos', {
method: 'POST',
body: JSON.stringify(newTodo),
})
return res.json()
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['todos'] })
},
})
const addTodo = () => {
mutate({ title: 'New Todo', completed: false })
}
</script>
<template>
<button @click="addTudo" :disabled="isPending">
{{ isPending ? 'Adding...' : 'Add Todo' }}
</button>
</template>
useInfiniteQuery
Implement infinite scrolling:
<script setup>
import { useInfiniteQuery } from '@tanstack/vue-query'
const {
data,
fetchNextPage,
hasNextPage,
isFetchingNextPage,
} = useInfiniteQuery({
queryKey: ['posts'],
queryFn: async ({ pageParam = 0 }) => {
const res = await fetch(`/api/posts?page=${pageParam}`)
return res.json()
},
getNextPageParam: (lastPage) => lastPage.nextCursor,
initialPageParam: 0,
})
</script>
<template>
<div>
<div v-for="page in data?.pages" :key="page.id">
<div v-for="post in page.posts" :key="post.id">
{{ post.title }}
</div>
</div>
<button
@click="fetchNextPage()"
:disabled="!hasNextPage || isFetchingNextPage"
>
{{ isFetchingNextPage ? 'Loading...' : 'Load More' }}
</button>
</div>
</template>
Reactivity Patterns
MaybeRef and MaybeRefDeep
Vue Query accepts reactive values throughout the options:
<script setup>
import { ref, computed } from 'vue'
import { useQuery } from '@tanstack/vue-query'
const enabled = ref(false)
const staleTime = ref(5000)
const userId = ref(1)
const { data } = useQuery({
queryKey: ['user', userId], // ref is automatically unwrapped
queryFn: () => fetchUser(userId.value),
enabled, // ref
staleTime, // ref
})
</script>
Watching Query Results
Query results are reactive refs that work with Vue’s watch:
<script setup>
import { watch } from 'vue'
import { useQuery } from '@tanstack/vue-query'
const { data, isSuccess } = useQuery({
queryKey: ['todos'],
queryFn: fetchTodos,
})
watch(data, (newData) => {
console.log('Todos updated:', newData)
})
watch(isSuccess, (success) => {
if (success) {
console.log('Query succeeded!')
}
})
</script>
Vue 2 vs Vue 3
Vue 2 with Composition API
<script>
import { defineComponent, ref } from '@vue/composition-api'
import { useQuery } from '@tanstack/vue-query'
export default defineComponent({
setup() {
const { data, isLoading } = useQuery({
queryKey: ['todos'],
queryFn: fetchTodos,
})
return { data, isLoading }
},
})
</script>
Vue 3 with Script Setup
<script setup>
import { useQuery } from '@tanstack/vue-query'
const { data, isLoading } = useQuery({
queryKey: ['todos'],
queryFn: fetchTodos,
})
</script>
The API is identical between Vue 2 and Vue 3. The only difference is how you access the Composition API.
Advanced Features
useQueries
Execute multiple queries in parallel:
<script setup>
import { ref } from 'vue'
import { useQueries } from '@tanstack/vue-query'
const userIds = ref([1, 2, 3])
const results = useQueries({
queries: userIds.value.map((id) => ({
queryKey: ['user', id],
queryFn: () => fetchUser(id),
})),
})
// results is an array of reactive query results
</script>
Query Options Factory
import { queryOptions } from '@tanstack/vue-query'
export const todoQueries = {
all: () => queryOptions({
queryKey: ['todos'],
queryFn: fetchTodos,
}),
detail: (id: number) => queryOptions({
queryKey: ['todos', id],
queryFn: () => fetchTodo(id),
}),
}
// Usage
const { data } = useQuery(todoQueries.detail(1))
useMutationState
Track all mutations globally:
<script setup>
import { useMutationState } from '@tanstack/vue-query'
const pendingMutations = useMutationState({
filters: { status: 'pending' },
})
</script>
<template>
<div v-if="pendingMutations.length > 0">
Saving {{ pendingMutations.length }} changes...
</div>
</template>
useIsFetching
Show a global loading indicator:
<script setup>
import { useIsFetching } from '@tanstack/vue-query'
const isFetching = useIsFetching()
</script>
<template>
<div v-if="isFetching" class="loading-bar">
Loading...
</div>
</template>
Shallow Option
Control ref unwrapping with the shallow option:
<script setup>
import { useQuery } from '@tanstack/vue-query'
const { data } = useQuery({
queryKey: ['todos'],
queryFn: fetchTodos,
shallow: true, // Prevents deep unwrapping of refs
})
</script>
TypeScript
Full TypeScript support with proper type inference:
<script setup lang="ts">
import { useQuery } from '@tanstack/vue-query'
import type { Ref } from 'vue'
interface Todo {
id: number
title: string
completed: boolean
}
const { data } = useQuery({
queryKey: ['todos'],
queryFn: async (): Promise<Todo[]> => {
const res = await fetch('/api/todos')
return res.json()
},
})
// data is typed as Ref<Todo[] | undefined>
</script>
Vue Query integrates with Vue DevTools:
import { VueQueryPlugin } from '@tanstack/vue-query'
app.use(VueQueryPlugin, {
enableDevtoolsV6Plugin: true, // Enables Vue DevTools integration
})
SSR Support
For Nuxt or other SSR frameworks:
// plugins/vue-query.ts
import { VueQueryPlugin, QueryClient } from '@tanstack/vue-query'
export default defineNuxtPlugin((nuxtApp) => {
const queryClient = new QueryClient({
defaultOptions: {
queries: {
staleTime: 5000,
},
},
})
nuxtApp.vueApp.use(VueQueryPlugin, { queryClient })
})
Vue-Specific Gotchas
Ref Unwrapping: Vue Query automatically unwraps refs in query options, but query results are returned as refs. Always access them with .value in script or directly in templates.
Reactive Options: You can pass entire options objects as refs or use individual refs for specific properties. Both approaches work seamlessly with Vue’s reactivity.