Web / TanStack Interview Questions
TanStack is a collection of open-source, framework-agnostic libraries by Tanner Linsley that eliminate the most complex, repetitive concerns in modern web apps. The suite includes TanStack Query (server-state caching), TanStack Router (type-safe routing), TanStack Table (headless table logic), TanStack Form (form-state management), and TanStack Virtual (virtualised list/grid rendering).
All libraries are headless — they ship logic and state but zero UI or CSS, so teams retain full markup control. Every library works with Vue, React, Solid, Svelte, Angular and Qwik through first-party adapters.
| Library | Responsibility | Vue package |
|---|---|---|
| TanStack Query v5 | Server state, caching, background refetching | @tanstack/vue-query |
| TanStack Router v1 | Type-safe routing, search params, loaders | @tanstack/vue-router |
| TanStack Table v8 | Sorting, filtering, pagination logic | @tanstack/vue-table |
| TanStack Form v1 | Form state, validation, field arrays | @tanstack/vue-form |
| TanStack Virtual v3 | Virtualised row/column rendering | @tanstack/vue-virtual |
TanStack Query wraps async data-fetching functions and provides automatic caching, background refetching, request deduplication, loading/error state, and cache invalidation — with zero manual cache code.
<!-- WITHOUT TanStack Query -->
<script setup lang="ts">
import { ref, onMounted } from "vue"
const user=ref(null), loading=ref(true), error=ref(null)
onMounted(async()=>{
try{ user.value=await fetch("/api/user/1").then(r=>r.json()) }
catch(e){ error.value=e } finally{ loading.value=false }
})
</script>
<!-- WITH TanStack Query -->
<script setup lang="ts">
import { useQuery } from "@tanstack/vue-query"
const { data:user, isPending, isError } = useQuery({
queryKey: ["user",1],
queryFn: ()=>fetch("/api/user/1").then(r=>r.json()),
})
</script>| Concern | Plain fetch | TanStack Query |
|---|---|---|
| Loading state | Manual ref(true/false) | isPending, isFetching auto-managed |
| Error state | Manual try/catch | isError, error auto-managed |
| Caching | None — re-fetches every mount | Cached by queryKey, configurable TTL |
| Deduplication | Duplicate concurrent requests | Single in-flight request per key |
| Background refresh | None | Auto on window focus / reconnect |
| Invalidation | Manual | queryClient.invalidateQueries() |
Create a QueryClient once in main.ts and register it via VueQueryPlugin. The client holds the cache and default options, and is accessible in any component via useQueryClient().
// main.ts
import { createApp } from "vue"
import { VueQueryPlugin, QueryClient } from "@tanstack/vue-query"
import App from "./App.vue"
const queryClient = new QueryClient({
defaultOptions: {
queries: {
staleTime: 1000*60*5, // 5 min fresh
gcTime: 1000*60*10, // 10 min in cache after unmount
retry: 2,
refetchOnWindowFocus: true,
},
},
})
createApp(App).use(VueQueryPlugin,{queryClient}).mount("#app")
// Any component
import { useQueryClient } from "@tanstack/vue-query"
const qc = useQueryClient()
async function save(){
await saveUser(form)
await qc.invalidateQueries({ queryKey:["users"] })
}
The queryKey is a serialisable array uniquely identifying a cached query. TanStack Query uses it to look up the cache, trigger refetches when it changes, and scope invalidations. Organise keys from most-general to most-specific.
// Hierarchical — enables partial-match invalidation
const allUsers = ["users"]
const userById = ["users",{id:42}]
const userPosts = ["users",{id:42},"posts"]
// Invalidate ALL user-related queries at once
queryClient.invalidateQueries({ queryKey:["users"] })
// Key-factory pattern — single source of truth, TypeScript catches typos
export const userKeys = {
all: () => ["users"] as const,
detail: (id:number) => ["users",{id}] as const,
posts: (id:number) => ["users",{id},"posts"] as const,
}
const { data } = useQuery({
queryKey: userKeys.detail(userId.value),
queryFn: ()=>fetchUser(userId.value),
})
These are the most commonly confused options — they govern completely different lifecycle phases.
| Option | Controls | Default |
|---|---|---|
| staleTime | How long cached data is 'fresh' — no background refetch while fresh | 0 ms (immediately stale) |
| gcTime | How long an unused cache entry (no subscribers) stays in memory before GC | 5 minutes |
const { data } = useQuery({
queryKey: ["config"],
queryFn: fetchConfig,
staleTime: 1000*60*10, // fresh 10 min — no background refetch
gcTime: 1000*60*30, // kept 30 min after last subscriber leaves
})
// t=0: fetched. FRESH.
// t=8min: remount. Still FRESH — cache hit, no network call.
// t=11min: remount. STALE — serves cache, refetches in background.
// t=35min: unmounted 30 min — cache REMOVED by GC.Tip: staleTime:Infinity means data never goes stale automatically — only explicit invalidateQueries() refreshes it. Ideal for app config or country lists.
TanStack Query exposes many options; the following solve the most common real-world patterns.
<script setup lang="ts">
import { computed, ref } from "vue"
import { useQuery, keepPreviousData } from "@tanstack/vue-query"
const props = defineProps<{ userId: number|null }>()
// enabled: skip query when userId is null
const { data:user } = useQuery({
queryKey: computed(()=>["user",props.userId]),
queryFn: ()=>fetchUser(props.userId!),
enabled: computed(()=>props.userId!==null),
})
// select: transform result; re-render only when selected slice changes
const { data:userName } = useQuery({
queryKey: ["user",1],
queryFn: ()=>fetchUser(1),
select: d=>d.name,
})
// refetchInterval: poll every 5 s
const { data:price } = useQuery({
queryKey: ["stock","AAPL"],
queryFn: ()=>fetchStockPrice("AAPL"),
refetchInterval: 5000,
refetchIntervalInBackground: false,
})
// placeholderData: show previous page while next loads
const page = ref(1)
const { data:posts } = useQuery({
queryKey: computed(()=>["posts",page.value]),
queryFn: ()=>fetchPosts(page.value),
placeholderData: keepPreviousData,
})
</script>
useMutation handles write operations (POST/PUT/DELETE). Unlike useQuery it does not run automatically — you call the returned mutate function explicitly. Lifecycle callbacks handle side effects like cache invalidation and notifications.
<script setup lang="ts">
import { useMutation, useQueryClient } from "@tanstack/vue-query"
const qc = useQueryClient()
const { mutate:createPost, isPending, isError, error } = useMutation({
mutationFn: (post:{title:string;body:string}) =>
fetch("/api/posts",{
method:"POST",
headers:{"Content-Type":"application/json"},
body:JSON.stringify(post),
}).then(r=>r.json()),
onSuccess: ()=>{ qc.invalidateQueries({queryKey:["posts"]}) },
onError: (err)=>{ console.error(err) },
onSettled: ()=>{ /* runs after success OR error */ },
})
</script>
<template>
<button :disabled="isPending"
@click="createPost({title:'Hi',body:'World'})">
{{ isPending ? "Saving..." : "Save Post" }}
</button>
<p v-if="isError">{{ error.message }}</p>
</template>
Use the enabled option with a computed() boolean. In Vue every option that reads reactive state must be wrapped in computed(); otherwise TanStack Query captures the value once at setup and never updates.
<script setup lang="ts">
import { computed } from "vue"
import { useQuery } from "@tanstack/vue-query"
// Step 1: fetch current session
const { data:session } = useQuery({
queryKey: ["session"],
queryFn: fetchCurrentSession,
})
// Step 2: only fetch projects once session is available
const userId = computed(()=>session.value?.userId)
const { data:projects } = useQuery({
queryKey: computed(()=>["projects",{userId:userId.value}]),
queryFn: ()=>fetchProjectsByUser(userId.value!),
enabled: computed(()=>userId.value!==undefined),
})
</script>Rule: wrap queryKey and enabled in computed() whenever they read reactive state — plain values are evaluated once at setup and never react to changes.
Standard pagination: plain useQuery with a page ref in the key plus keepPreviousData to avoid blank states. Infinite scroll: useInfiniteQuery accumulates pages automatically.
<script setup lang="ts">
import { ref, computed } from "vue"
import { useQuery, useInfiniteQuery, keepPreviousData } from "@tanstack/vue-query"
// Pattern 1 — paginated table
const page = ref(1)
const { data:postsPage } = useQuery({
queryKey: computed(()=>["posts",{page:page.value}]),
queryFn: ()=>fetchPosts({page:page.value,limit:10}),
placeholderData: keepPreviousData,
})
// Pattern 2 — infinite / "Load more"
const { data, fetchNextPage, hasNextPage, isFetchingNextPage } = useInfiniteQuery({
queryKey: ["posts","infinite"],
queryFn: ({pageParam})=>fetchPosts({page:pageParam,limit:10}),
initialPageParam: 1,
getNextPageParam: (last,all)=>last.hasMore ? all.length+1 : undefined,
})
const allPosts = computed(()=>data.value?.pages.flatMap(p=>p.items)??[])
</script>
<template>
<article v-for="p in allPosts" :key="p.id">{{ p.title }}</article>
<button @click="fetchNextPage()" :disabled="!hasNextPage||isFetchingNextPage">
{{ isFetchingNextPage?"Loading...":"Load more" }}
</button>
</template>
Prefetching loads data into the cache before it is needed — on hover, on route entry, or alongside the current page. When the user navigates, data is already cached and renders instantly.
<script setup lang="ts">
import { useQueryClient } from "@tanstack/vue-query"
import { ref, watchEffect } from "vue"
const qc = useQueryClient()
// Prefetch on hover
async function prefetchPost(id:number){
await qc.prefetchQuery({
queryKey: ["post",id],
queryFn: ()=>fetchPost(id),
staleTime: 1000*60, // skip if cached < 1 min
})
}
// Prefetch next page while user reads current
const page=ref(1)
watchEffect(()=>{
qc.prefetchQuery({
queryKey: ["posts",page.value+1],
queryFn: ()=>fetchPosts(page.value+1),
})
})
</script>
<template>
<RouterLink v-for="p in data?.items" :key="p.id"
:to="`/posts/${p.id}`"
@mouseenter="prefetchPost(p.id)">{{ p.title }}</RouterLink>
</template>
TanStack Router is a fully type-safe client-side router with end-to-end TypeScript inference for route params, search params, and loaders. A misspelled param or a navigation to a non-existent route is a compile-time error, not a runtime bug.
| Feature | TanStack Router | Vue Router |
|---|---|---|
| Type safety | Full inference on params, search params, loaders | Manual typing via module augmentation |
| Search params | First-class typed, validated, serialised state | Plain string parsing, manual validation |
| Data loading | Built-in route loaders with pending/error UI | Manual Pinia/Query integration |
| File-based routing | Yes (optional) | Not built-in |
// main.ts
import { createRouter } from "@tanstack/vue-router"
import { rootRoute } from "./routes/__root"
export const router = createRouter({
routeTree: rootRoute,
defaultPreload: "intent", // prefetch on hover
})
createApp(App).use(router).mount("#app")
// Type-safe navigation
import { useNavigate } from "@tanstack/vue-router"
const navigate = useNavigate()
navigate({ to:"/users/$userId", params:{ userId:"42" } })
// TS error if "userId" is not a valid param for that route
Use createRootRoute() for the layout root and createRoute() for each page. Routes are assembled into a typed tree and passed to createRouter().
// routes/__root.ts
import { createRootRoute, RouterOutlet } from "@tanstack/vue-router"
import { h } from "vue"
export const rootRoute = createRootRoute({
component: ()=>h("div",null,[h(RouterOutlet)]),
})
// routes/users/$userId.ts — $ marks a dynamic segment
import { createRoute } from "@tanstack/vue-router"
import UserDetailPage from "../pages/UserDetailPage.vue"
export const userDetailRoute = createRoute({
getParentRoute: ()=>rootRoute,
path: "/users/$userId",
component: UserDetailPage,
})
// Assemble the tree
const routeTree = rootRoute.addChildren([userDetailRoute])
export const router = createRouter({ routeTree })
// Access typed params in a component
import { useParams } from "@tanstack/vue-router"
const { userId } = useParams({ from:"/users/$userId" })
// userId typed as string; TS error on wrong param name
Declare a validateSearch schema on the route. Search params become typed, validated, URL-persistent state — perfect for filters, sort order, and pagination that must survive refresh and be shareable via URL.
import { createRoute } from "@tanstack/vue-router"
import { z } from "zod"
export const postsRoute = createRoute({
getParentRoute: ()=>rootRoute,
path: "/posts",
component: PostsPage,
validateSearch: z.object({
page: z.number().int().positive().default(1),
status: z.enum(["all","published","draft"]).default("all"),
q: z.string().optional(),
}).parse,
})
// In the component
import { useSearch, useNavigate } from "@tanstack/vue-router"
const search = useSearch({ from:"/posts" })
// Typed: { page:number, status:"all"|"published"|"draft", q?:string }
const navigate = useNavigate()
function setPage(n:number){
navigate({ to:"/posts", search: prev=>({...prev, page:n}) })
}
Route loaders run before the component renders. Using queryClient.ensureQueryData() inside a loader pre-populates the Query cache so the component renders synchronously with no loading spinner.
import { createRoute } from "@tanstack/vue-router"
import { queryClient } from "../queryClient"
import { userKeys } from "../queries/userKeys"
export const userDetailRoute = createRoute({
getParentRoute: ()=>rootRoute,
path: "/users/$userId",
component: UserDetailPage,
loader: async ({ params })=>{
await queryClient.ensureQueryData({
queryKey: userKeys.detail(Number(params.userId)),
queryFn: ()=>fetchUser(Number(params.userId)),
})
// Component will find data already in cache — renders instantly
},
})
// In UserDetailPage.vue
import { useParams } from "@tanstack/vue-router"
import { useQuery } from "@tanstack/vue-query"
const { userId } = useParams({ from:"/users/$userId" })
const { data:user } = useQuery({
queryKey: userKeys.detail(Number(userId)),
queryFn: ()=>fetchUser(Number(userId)),
})
TanStack Table v8 is a headless table engine handling sorting, filtering, pagination, selection, grouping and virtualisation. It returns a typed table object with methods and state you use in your own markup — it renders nothing itself.
<script setup lang="ts">
import {
useVueTable, createColumnHelper, getCoreRowModel,
getSortedRowModel, FlexRender,
} from "@tanstack/vue-table"
import { ref } from "vue"
type Person = { id:number; name:string; age:number }
const data = ref<Person[]>([
{ id:1, name:"Alice", age:30 },
{ id:2, name:"Bob", age:25 },
])
const ch = createColumnHelper<Person>()
const columns = [
ch.accessor("name", { header:"Name", enableSorting:true }),
ch.accessor("age", { header:"Age", enableSorting:true }),
]
const table = useVueTable({
get data(){ return data.value },
columns,
getCoreRowModel: getCoreRowModel(),
getSortedRowModel: getSortedRowModel(),
})
</script>
<template>
<table>
<thead>
<tr v-for="hg in table.getHeaderGroups()" :key="hg.id">
<th v-for="h in hg.headers" :key="h.id"
@click="h.column.getToggleSortingHandler()?.($event)">
<FlexRender :render="h.column.columnDef.header" :props="h.getContext()" />
</th>
</tr>
</thead>
<tbody>
<tr v-for="row in table.getRowModel().rows" :key="row.id">
<td v-for="cell in row.getVisibleCells()" :key="cell.id">
<FlexRender :render="cell.column.columnDef.cell" :props="cell.getContext()" />
</td>
</tr>
</tbody>
</table>
</template>
Pass getSortedRowModel() and getFilteredRowModel() to useVueTable. Use controlled refs for state so sort/filter can be persisted to the URL or storage.
<script setup lang="ts">
import {
useVueTable, createColumnHelper,
getCoreRowModel, getSortedRowModel, getFilteredRowModel,
type SortingState,
} from "@tanstack/vue-table"
import { ref } from "vue"
type Employee = { name:string; department:string; salary:number }
const data=ref<Employee[]>([/*...*/])
const sorting=ref<SortingState>([])
const globalFilter=ref("")
const ch = createColumnHelper<Employee>()
const columns = [
ch.accessor("name",{header:"Name",enableSorting:true}),
ch.accessor("department",{header:"Department",enableSorting:true}),
ch.accessor("salary",{header:"Salary",enableSorting:true}),
]
const table = useVueTable({
get data(){ return data.value },
columns,
state:{
get sorting(){ return sorting.value },
get globalFilter(){ return globalFilter.value },
},
onSortingChange: v=>{ sorting.value=typeof v==="function"?v(sorting.value):v },
onGlobalFilterChange: v=>{ globalFilter.value=v },
getCoreRowModel: getCoreRowModel(),
getSortedRowModel: getSortedRowModel(),
getFilteredRowModel: getFilteredRowModel(),
})
</script>
<template>
<input v-model="globalFilter" placeholder="Search..." />
</template>
Add getPaginationRowModel() to the table options. The table exposes page-navigation helpers and pagination state. Controlled pagination allows URL sync.
<script setup lang="ts">
import {
useVueTable, createColumnHelper,
getCoreRowModel, getPaginationRowModel,
} from "@tanstack/vue-table"
import { ref } from "vue"
const data=ref(largeDataset)
const table=useVueTable({
get data(){ return data.value },
columns,
getCoreRowModel: getCoreRowModel(),
getPaginationRowModel: getPaginationRowModel(),
initialState: { pagination:{ pageSize:20, pageIndex:0 } },
})
</script>
<template>
<tr v-for="row in table.getRowModel().rows" :key="row.id">
<td v-for="cell in row.getVisibleCells()" :key="cell.id">...</td>
</tr>
<div>
<button @click="table.previousPage()" :disabled="!table.getCanPreviousPage()">Prev</button>
<span>Page {{ table.getState().pagination.pageIndex+1 }} of {{ table.getPageCount() }}</span>
<button @click="table.nextPage()" :disabled="!table.getCanNextPage()">Next</button>
<select :value="table.getState().pagination.pageSize"
@change="table.setPageSize(Number(($event.target as HTMLSelectElement).value))">
<option v-for="n in [10,20,50]" :key="n" :value="n">Show {{n}}</option>
</select>
</div>
</template>
Both are built-in. Column visibility lets users show/hide columns; row selection provides a managed selected-rows map for bulk actions.
<script setup lang="ts">
import {
useVueTable, createColumnHelper, getCoreRowModel,
type VisibilityState, type RowSelectionState,
} from "@tanstack/vue-table"
import { h, ref, computed } from "vue"
const colVis=ref<VisibilityState>({})
const rowSel=ref<RowSelectionState>({})
const ch=createColumnHelper<Person>()
const columns=[
ch.display({
id:"select",
header:({table})=>h("input",{type:"checkbox",
checked:table.getIsAllPageRowsSelected(),
onChange:table.getToggleAllPageRowsSelectedHandler()}),
cell:({row})=>h("input",{type:"checkbox",
checked:row.getIsSelected(),
onChange:row.getToggleSelectedHandler()}),
}),
ch.accessor("name",{header:"Name"}),
ch.accessor("email",{header:"Email"}),
]
const table=useVueTable({
get data(){ return data.value }, columns,
state:{
get columnVisibility(){ return colVis.value },
get rowSelection(){ return rowSel.value },
},
onColumnVisibilityChange: v=>{ colVis.value=typeof v==="function"?v(colVis.value):v },
onRowSelectionChange: v=>{ rowSel.value=typeof v==="function"?v(rowSel.value):v },
getCoreRowModel: getCoreRowModel(),
enableRowSelection: true,
})
const selected=computed(()=>table.getSelectedRowModel().rows.map(r=>r.original))
</script>
<template>
<label v-for="col in table.getAllLeafColumns()" :key="col.id">
<input type="checkbox" :checked="col.getIsVisible()" @change="col.toggleVisibility()" />
{{col.id}}
</label>
<p>{{selected.length}} rows selected</p>
</template>
TanStack Form is a headless, type-safe form state library with built-in async validation, field arrays and subscriptions. Unlike Vue-specific libraries (vee-validate, Vuelidate) it is framework-agnostic — the same library targets Vue, React and Solid via adapters.
| Feature | TanStack Form | vee-validate | Vuelidate |
|---|---|---|---|
| TypeScript inference | First-class — field types inferred | Good with macros | Partial |
| Async validation | Built-in per-field | Via Yup / custom | Via custom |
| Framework | Agnostic (adapters) | Vue-specific | Vue-specific |
| Field arrays | Built-in | Via FieldArray component | Manual |
<script setup lang="ts">
import { useForm } from "@tanstack/vue-form"
const form = useForm({
defaultValues: { username:"", email:"", age:0 },
onSubmit: async ({ value })=>{
// value fully typed: { username:string, email:string, age:number }
await createUser(value)
},
})
</script>
<template>
<form @submit.prevent="form.handleSubmit()">
<form.Field name="username">
<template #default="{ field }">
<input :value="field.state.value"
@blur="field.handleBlur"
@input="field.handleChange(($event.target as HTMLInputElement).value)" />
<span v-if="field.state.meta.errors.length">
{{ field.state.meta.errors[0] }}
</span>
</template>
</form.Field>
<button type="submit" :disabled="form.state.isSubmitting">Save</button>
</form>
</template>
Validators run on onChange, onBlur, or onSubmit, synchronously or asynchronously. Async validators support debounce to avoid API calls on every keystroke.
<script setup lang="ts">
import { useForm } from "@tanstack/vue-form"
import { zodValidator } from "@tanstack/zod-form-adapter"
import { z } from "zod"
const form=useForm({
defaultValues: { username:"", email:"" },
validatorAdapter: zodValidator(),
onSubmit: async ({ value })=>{ await register(value) },
})
</script>
<template>
<form @submit.prevent="form.handleSubmit()">
<!-- Sync Zod validation -->
<form.Field name="username"
:validators="{
onChange: z.string().min(3,'Min 3 characters'),
onBlur: z.string().regex(/^[a-z0-9]+$/,'Lowercase/numbers only'),
}"
>
<template #default="{ field }">
<input :value="field.state.value" @blur="field.handleBlur"
@input="field.handleChange(($event.target as HTMLInputElement).value)" />
<p v-for="e in field.state.meta.errors" :key="e">{{e}}</p>
</template>
</form.Field>
<!-- Async validation with debounce -->
<form.Field name="email"
:validators="{
onChangeAsync: async ({ value })=>{
const taken=await checkEmailTaken(value)
return taken ? 'Email already registered' : undefined
},
onChangeAsyncDebounceMs: 500,
}"
>
<template #default="{ field }">
<input :value="field.state.value"
@input="field.handleChange(($event.target as HTMLInputElement).value)" />
<span v-if="field.state.meta.isValidating">Checking...</span>
<p v-for="e in field.state.meta.errors" :key="e">{{e}}</p>
</template>
</form.Field>
<button type="submit">Register</button>
</form>
</template>
form.Field with mode='array' provides pushValue, removeValue, and moveValue for dynamic lists such as phone numbers, addresses, or order line items.
<script setup lang="ts">
import { useForm } from "@tanstack/vue-form"
const form=useForm({
defaultValues: { friends:[{ name:"", age:0 }] },
onSubmit: ({ value })=>console.log(value),
})
</script>
<template>
<form @submit.prevent="form.handleSubmit()">
<form.Field name="friends" mode="array">
<template #default="ff">
<div v-for="(_,i) in ff.state.value" :key="i">
<form.Field :name="`friends[${i}].name`">
<template #default="{ field }">
<input :placeholder="`Friend ${i+1} name`"
:value="field.state.value"
@input="field.handleChange(($event.target as HTMLInputElement).value)" />
</template>
</form.Field>
<button type="button" @click="ff.removeValue(i)">Remove</button>
</div>
<button type="button" @click="ff.pushValue({ name:'', age:0 })">Add friend</button>
</template>
</form.Field>
<button type="submit">Save</button>
</form>
</template>
TanStack Virtual renders only the DOM nodes visible in the viewport. With thousands of rows, rendering everything creates thousands of DOM nodes causing slow renders, janky scroll, and high memory use. TanStack Virtual computes which items are in view and renders only those plus a small overscan buffer.
<script setup lang="ts">
import { useVirtualizer } from "@tanstack/vue-virtual"
import { ref, computed } from "vue"
const parentRef=ref<HTMLDivElement|null>(null)
const items=Array.from({length:10_000},(_,i)=>({id:i,name:`Item ${i+1}`}))
const virt=useVirtualizer({
count: items.length,
getScrollElement: ()=>parentRef.value,
estimateSize: ()=>40,
overscan: 5,
})
const vRows = computed(()=>virt.value.getVirtualItems())
const totalH = computed(()=>virt.value.getTotalSize())
</script>
<template>
<div ref="parentRef" style="height:500px;overflow-y:auto;">
<div :style="{ height:`${totalH}px`, position:'relative' }">
<div v-for="vr in vRows" :key="vr.key"
:style="{ position:'absolute',top:0,
transform:`translateY(${vr.start}px)`,
height:`${vr.size}px`,width:'100%' }">
{{ items[vr.index].name }}
</div>
</div>
</div>
</template>
Pass a measureElement callback so TanStack Virtual reads each row's actual rendered height and updates its internal size map. Provide estimateSize for initial layout before items are mounted.
<script setup lang="ts">
import { useVirtualizer } from "@tanstack/vue-virtual"
import { ref, computed } from "vue"
const parentRef=ref<HTMLDivElement|null>(null)
const messages=ref(largeMessageList)
const virt=useVirtualizer({
count: computed(()=>messages.value.length).value,
getScrollElement: ()=>parentRef.value,
estimateSize: ()=>80,
overscan: 5,
measureElement: el=>el?.getBoundingClientRect().height,
})
const vItems = computed(()=>virt.value.getVirtualItems())
const totalH = computed(()=>virt.value.getTotalSize())
</script>
<template>
<div ref="parentRef" style="height:600px;overflow-y:auto;">
<div :style="{ height:`${totalH}px`, position:'relative' }">
<div v-for="vi in vItems" :key="vi.key"
:ref="node=>virt.measureElement(node)"
:data-index="vi.index"
:style="{ position:'absolute',top:0,
transform:`translateY(${vi.start}px)`,width:'100%' }">
<MessageCard :message="messages[vi.index]" />
</div>
</div>
</div>
</template>
This is one of the most powerful TanStack patterns: useInfiniteQuery fetches pages as the user scrolls while useVirtualizer renders only visible DOM nodes — together they handle millions of rows with minimal memory.
<script setup lang="ts">
import { ref, computed, watchEffect } from "vue"
import { useInfiniteQuery } from "@tanstack/vue-query"
import { useVirtualizer } from "@tanstack/vue-virtual"
const parentRef=ref<HTMLDivElement|null>(null)
const { data, fetchNextPage, hasNextPage, isFetchingNextPage }=useInfiniteQuery({
queryKey: ["posts","infinite"],
queryFn: ({pageParam})=>fetchPosts({page:pageParam}),
initialPageParam: 0,
getNextPageParam: last=>last.nextPage??undefined,
})
const allRows=computed(()=>data.value?data.value.pages.flatMap(p=>p.rows):[])
const virt=useVirtualizer({
// +1 sentinel row triggers next-page fetch when it enters viewport
get count(){ return hasNextPage.value?allRows.value.length+1:allRows.value.length },
getScrollElement: ()=>parentRef.value,
estimateSize: ()=>72,
overscan: 5,
})
const vItems=computed(()=>virt.value.getVirtualItems())
watchEffect(()=>{
const last=vItems.value.at(-1)
if(!last) return
if(last.index>=allRows.value.length-1 && hasNextPage.value && !isFetchingNextPage.value)
fetchNextPage()
})
</script>
Optimistic updates change the UI immediately before the server responds. On error the UI rolls back to the snapshot saved in onMutate.
const { mutate:toggleTodo }=useMutation({
mutationFn: (todo:Todo)=>
fetch(`/api/todos/${todo.id}`,{
method:"PATCH",
body:JSON.stringify({done:!todo.done}),
}).then(r=>r.json()),
onMutate: async (todo)=>{
// Cancel in-flight fetches so they don't overwrite our update
await qc.cancelQueries({queryKey:["todos"]})
const prev=qc.getQueryData<Todo[]>(["todos"])
qc.setQueryData<Todo[]>(["todos"],
old=>old?.map(t=>t.id===todo.id?{...t,done:!t.done}:t)??[])
return { prev }
},
onError: (_,__,ctx)=>{
if(ctx?.prev) qc.setQueryData(["todos"],ctx.prev)
},
onSettled: ()=>{ qc.invalidateQueries({queryKey:["todos"]}) },
})
v5 separates state into two orthogonal dimensions: status (what does the cache contain?) and fetchStatus (is a network request happening?). Understanding both prevents wrong loading UI.
| status | fetchStatus | Meaning |
|---|---|---|
| pending | fetching | No data yet, first load — show full-page spinner |
| pending | idle | enabled=false — query paused, will never fetch |
| success | fetching | Has data, refetching in background — show subtle indicator |
| success | idle | Has fresh data, nothing happening — steady state |
| error | idle | All retries exhausted — show error UI |
<script setup lang="ts">
import { useQuery } from "@tanstack/vue-query"
const {
data, isPending, isSuccess, isError,
isFetching, // fetchStatus==="fetching"
isLoading, // isPending && isFetching — true ONLY on first load
error,
}=useQuery({ queryKey:["posts"], queryFn:fetchPosts })
</script>
<template>
<LoadingSpinner v-if="isLoading" />
<ErrorMessage v-else-if="isError" :msg="error.message" />
<div v-else>
<RefetchBadge v-if="isFetching" />
<PostList :posts="data" />
</div>
</template>
TanStack Router provides <RouterLink> for declarative links and useNavigate() for programmatic navigation. Both are fully type-safe — TypeScript errors on invalid paths or missing params.
<script setup lang="ts">
import { RouterLink, RouterOutlet, useNavigate } from "@tanstack/vue-router"
const navigate=useNavigate()
async function handleLogin(creds){
await login(creds)
navigate({ to:"/dashboard", replace:true })
}
function openUser(id:number){
navigate({ to:"/users/$userId", params:{userId:String(id)}, search:{tab:"profile"} })
}
</script>
<template>
<RouterLink to="/home">Home</RouterLink>
<RouterLink
v-for="u in users" :key="u.id"
:to="{ to:'/users/$userId', params:{ userId:String(u.id) } }"
:active-props="{ class:'font-bold text-blue-600' }"
:preload="'intent'"
>{{ u.name }}</RouterLink>
<RouterOutlet />
</template>
Each route accepts errorComponent and pendingComponent. When a loader throws, errorComponent renders instead of the page — no flash of protected content, no try/catch in every component.
import { createRoute } from "@tanstack/vue-router"
import { defineComponent, h } from "vue"
const RouteError=defineComponent({
props: { error:{ type:Error, required:true } },
setup(props){
return ()=>h("div",null,[
h("h2","Something went wrong"),
h("p",props.error.message),
])
},
})
const RoutePending=defineComponent({
setup:()=>()=>h("div","Loading..."),
})
export const userDetailRoute=createRoute({
getParentRoute: ()=>rootRoute,
path: "/users/$userId",
component: UserDetailPage,
pendingComponent: RoutePending,
pendingMs: 200, // only show pending if loader > 200 ms — avoids flash
errorComponent: RouteError,
loader: async ({ params })=>{
const user=await fetchUser(Number(params.userId))
if(!user) throw new Error("User not found")
return { user }
},
})
Set manualSorting and manualPagination to true. TanStack Table manages UI state (current sort, page) but does not process rows — you read that state and send it to your API.
<script setup lang="ts">
import { ref, computed } from "vue"
import { useQuery, keepPreviousData } from "@tanstack/vue-query"
import { useVueTable, createColumnHelper, getCoreRowModel,
type SortingState, type PaginationState } from "@tanstack/vue-table"
const sorting = ref<SortingState>([])
const pagination = ref<PaginationState>({ pageIndex:0, pageSize:20 })
const { data:serverData } = useQuery({
queryKey: computed(()=>["users",{ sort:sorting.value[0], ...pagination.value }]),
queryFn: ()=>fetchUsers({
sortBy: sorting.value[0]?.id,
sortDir: sorting.value[0]?.desc?"desc":"asc",
page: pagination.value.pageIndex,
pageSize: pagination.value.pageSize,
}),
placeholderData: keepPreviousData,
})
const ch=createColumnHelper<User>()
const columns=[ch.accessor("name",{header:"Name",enableSorting:true})]
const table=useVueTable({
get data(){ return serverData.value?.rows??[] },
get rowCount(){ return serverData.value?.total??0 },
columns,
manualSorting: true,
manualPagination: true,
state:{
get sorting(){ return sorting.value },
get pagination(){ return pagination.value },
},
onSortingChange: v=>{ sorting.value=typeof v==="function"?v(sorting.value):v },
onPaginationChange: v=>{ pagination.value=typeof v==="function"?v(pagination.value):v },
getCoreRowModel: getCoreRowModel(),
})
</script>
TanStack Query and TanStack Router each ship a devtools panel. Query devtools visualise the cache, let you manually invalidate/refetch, and show observer counts. Router devtools show the matched route tree, params and search params in real time. Both are tree-shaken in production.
<!-- @tanstack/vue-query-devtools -->
<script setup lang="ts">
import { VueQueryDevtools } from "@tanstack/vue-query-devtools"
</script>
<template>
<RouterView />
<!-- Automatically excluded from production build -->
<VueQueryDevtools :initial-is-open="false" position="bottom-right" />
</template>
<!-- @tanstack/vue-router-devtools -->
<script setup lang="ts">
import { TanStackRouterDevtools } from "@tanstack/vue-router-devtools"
</script>
<template>
<RouterOutlet />
<TanStackRouterDevtools :router="router" position="bottom-left" />
</template>
A query key factory centralises all key definitions in one file. This makes invalidations predictable, lets TypeScript catch typos at compile time, and means a renaming/restructuring only needs to happen in one place.
// queryKeys.ts
export const userKeys={
all: () => ["users"] as const,
lists: () => ["users","list"] as const,
list: (f:UserFilter) => ["users","list",f] as const,
details: () => ["users","detail"] as const,
detail: (id:number) => ["users","detail",id] as const,
}
// Component
import { userKeys } from "@/queryKeys"
const { data:user }=useQuery({
queryKey: userKeys.detail(userId.value),
queryFn: ()=>fetchUser(userId.value),
})
// Mutation onSuccess — invalidate ALL user queries
const { mutate:deleteUser }=useMutation({
mutationFn: (id:number)=>fetch(`/api/users/${id}`,{method:"DELETE"}),
onSuccess: ()=>{ qc.invalidateQueries({queryKey:userKeys.all()}) },
})
// TypeScript catches typos:
// userKeys.detial(1) // TS Error: Property 'detial' does not exist
// ["users","detial",1] // No error — typo silently creates wrong key
Form-level validators receive the entire form value, enabling rules like 'password must match confirmPassword'. The onSubmit callback exposes formApi to reset or set field errors programmatically after a server response.
<script setup lang="ts">
import { useForm } from "@tanstack/vue-form"
const form=useForm({
defaultValues: { password:"", confirm:"" },
validators:{
onSubmit: ({ value })=>{
if(value.password!==value.confirm) return "Passwords do not match"
return undefined
},
},
onSubmit: async ({ value, formApi })=>{
try{
await registerUser(value)
formApi.reset()
} catch(err){
formApi.setFieldMeta("confirm", meta=>({...meta, errors:["Server error: try again"]}))
}
},
})
</script>
<template>
<form @submit.prevent="form.handleSubmit()">
<p v-if="form.state.errors.length" class="form-error">
{{ form.state.errors[0] }}
</p>
<!-- ...fields... -->
<button type="submit" :disabled="form.state.isSubmitting">Register</button>
</form>
</template>
The mutationFn, onMutate, onSuccess, onError, and onSettled callbacks all receive the mutation variables as their first argument. The exposed variables ref also holds the in-flight variables for use in the template.
<script setup lang="ts">
import { useMutation, useQueryClient } from "@tanstack/vue-query"
const qc=useQueryClient()
interface UpdateInput { id:number; name:string; email:string }
const { mutate:updateUser, isPending, variables }=useMutation({
mutationFn: (input:UpdateInput)=>
fetch(`/api/users/${input.id}`,{
method:"PUT",
body:JSON.stringify(input),
}).then(r=>r.json()),
onSuccess: (data, vars)=>{
qc.setQueryData(["user",vars.id], data) // write response directly into cache
toast.success(`Updated ${vars.name}`)
},
onError: (err, vars)=>{
toast.error(`Failed to update ${vars.name}: ${err.message}`)
},
})
</script>
<template>
<button @click="updateUser(form)" :disabled="isPending">
{{ isPending ? `Saving ${variables?.name}...` : "Save" }}
</button>
</template>
TanStack Virtual supports vertical (default), horizontal (horizontal:true), and grid layouts using two virtualizers — one per axis. Only the cells whose row AND column are both in view are rendered.
<script setup lang="ts">
import { useVirtualizer } from "@tanstack/vue-virtual"
import { ref, computed } from "vue"
const parentRef=ref<HTMLDivElement|null>(null)
const ROW_COUNT=1000, COL_COUNT=50
const rowVirt=useVirtualizer({
count: ROW_COUNT,
getScrollElement: ()=>parentRef.value,
estimateSize: ()=>35,
overscan: 5,
})
const colVirt=useVirtualizer({
horizontal: true,
count: COL_COUNT,
getScrollElement: ()=>parentRef.value,
estimateSize: ()=>120,
overscan: 5,
})
const vRows=computed(()=>rowVirt.value.getVirtualItems())
const vCols=computed(()=>colVirt.value.getVirtualItems())
const totW =computed(()=>colVirt.value.getTotalSize())
const totH =computed(()=>rowVirt.value.getTotalSize())
</script>
<template>
<div ref="parentRef" style="height:400px;width:800px;overflow:auto;">
<div :style="{ height:`${totH}px`, width:`${totW}px`, position:'relative' }">
<div v-for="vr in vRows" :key="vr.key"
:style="{ position:'absolute',top:0,
transform:`translateY(${vr.start}px)`,width:'100%' }">
<div v-for="vc in vCols" :key="vc.key"
:style="{ position:'absolute',left:0,
transform:`translateX(${vc.start}px)`,
width:`${vc.size}px`,height:`${vr.size}px` }">
Cell ({{vr.index}},{{vc.index}})
</div>
</div>
</div>
</div>
</template>
Knowing what not to do is as important as knowing the API. These patterns trip up most developers new to TanStack Query in Vue.
| Mistake | Problem | Fix |
|---|---|---|
| queryKey not including all query variables | Stale data served when variable changes | Add every queryFn variable to the key |
| Non-reactive queryKey in Vue | Query doesn't re-run when props change | Wrap queryKey in computed() when it reads reactive state |
| Server state in both TanStack Query and Pinia | Two sources of truth — sync bugs | Use TanStack Query as the single source for server data |
| New QueryClient per component | Each component gets its own empty cache | Create QueryClient once in main.ts |
| staleTime=0 for slow/static data | Unnecessary refetch on every window focus | Set staleTime to match data update frequency |
// WRONG: non-reactive key — captured once at setup, never updates
const { data }=useQuery({
queryKey: ["user", props.userId], // plain read, not reactive
queryFn: ()=>fetchUser(props.userId),
})
// CORRECT: reactive via computed()
const { data }=useQuery({
queryKey: computed(()=>["user", props.userId]),
queryFn: ()=>fetchUser(props.userId),
})
// WRONG: duplicating server state into Pinia
const { data:user }=useQuery({
queryKey: ["user",1],
queryFn: fetchUser,
onSuccess: u=>userStore.setUser(u), // unnecessary sync — two sources of truth
})
// CORRECT: read data directly from useQuery wherever needed
Use beforeLoad with throw redirect(). This halts the route loading process before the component ever mounts — no flash of protected content.
import { createRoute, redirect } from "@tanstack/vue-router"
import { getAuthToken } from "../auth"
async function requireAuth({ location }:any){
if(!getAuthToken())
throw redirect({ to:"/login", search:{ redirect:location.href } })
}
export const dashboardRoute=createRoute({
getParentRoute: ()=>rootRoute,
path: "/dashboard",
component: DashboardPage,
beforeLoad: requireAuth,
})
export const loginRoute=createRoute({
getParentRoute: ()=>rootRoute,
path: "/login",
component: LoginPage,
beforeLoad: ({ search })=>{
if(getAuthToken())
throw redirect({ to:(search as any).redirect??"/dashboard", replace:true })
},
})
// In LoginPage.vue
import { useSearch, useNavigate } from "@tanstack/vue-router"
const search=useSearch({ from:"/login" })
const nav=useNavigate()
async function handleLogin(creds){
await login(creds)
nav({ to:(search as any).redirect??"/dashboard", replace:true })
}
The cell column definition accepts a function returning a string, VNode, or component. FlexRender handles all three transparently so you never need conditional render logic.
<script setup lang="ts">
import { h, defineComponent, computed } from "vue"
import {
useVueTable, createColumnHelper, getCoreRowModel, FlexRender,
} from "@tanstack/vue-table"
type Order={ id:number; status:string; amount:number }
const StatusBadge=defineComponent({
props:{ status:String },
setup(props){
const colour=computed(()=>({
paid:"bg-green-100 text-green-800",
pending:"bg-yellow-100 text-yellow-800",
cancelled:"bg-red-100 text-red-800",
}[props.status??"pending"]??"bg-gray-100"))
return ()=>h("span",{class:colour.value},props.status)
},
})
const ch=createColumnHelper<Order>()
const columns=[
ch.accessor("status",{
header:"Status",
cell: info=>h(StatusBadge,{status:info.getValue()}),
}),
ch.accessor("amount",{
header:"Amount",
cell: info=>`$${info.getValue().toFixed(2)}`,
}),
ch.display({
id:"actions",
header:"Actions",
cell: ({row})=>h("div",null,[
h("button",{onClick:()=>editOrder(row.original)},"Edit"),
h("button",{onClick:()=>deleteOrder(row.original.id)},"Delete"),
]),
}),
]
</script>
Each library is independently useful, but together they form a coherent, type-safe, headless application stack — no additional state management or routing library required.
| Layer | Library | Responsibility |
|---|---|---|
| Routing | TanStack Router | Type-safe navigation, search params, loaders |
| Server state | TanStack Query | Fetching, caching, mutations, background sync |
| Form state | TanStack Form | Form lifecycle, validation, field arrays |
| Table / data UI | TanStack Table | Sorting, filtering, pagination, selection |
| Large lists | TanStack Virtual | Render only visible rows/columns |
// Typical page: Router search params → Query key → Table state
<script setup lang="ts">
import { computed } from "vue"
import { useSearch, useNavigate } from "@tanstack/vue-router"
import { useQuery } from "@tanstack/vue-query"
import { useVueTable, getCoreRowModel, createColumnHelper } from "@tanstack/vue-table"
// 1. URL state via Router
const search=useSearch({ from:"/users" })
// 2. Server data via Query (key mirrors URL state)
const { data }=useQuery({
queryKey: computed(()=>["users",{page:search.page,q:search.q}]),
queryFn: ()=>fetchUsers({page:search.page,q:search.q}),
})
// 3. Table UI logic via Table
const ch=createColumnHelper<User>()
const table=useVueTable({
get data(){ return data.value?.users??[] },
columns:[ch.accessor("name",{header:"Name"})],
manualPagination:true,
getCoreRowModel:getCoreRowModel(),
})
</script>
// TanStack Start (2024-2025):
// Full-stack framework built on TanStack Router adding SSR,
// server functions, streaming, and file-based routing.
// Stable for React; Vue support in progress.
