| name | vue/compose-with-tanstack-query |
| description | Server-side / async data flow for `@tanstack/vue-table` v9 + `@tanstack/vue-query`. Key the `useQuery` query on the table state that drives the request (pagination + sorting + filters). Set `manualPagination` / `manualSorting` / `manualFiltering` for slices the server owns. Drop the matching `_rowModels` factory (don't ship `paginatedRowModel` when the server paginates). Pass `rowCount` so `table.getPageCount()` works without all rows in memory. Pass `placeholderData: keepPreviousData` to avoid a "0 rows flash" between pages. The cleanest wiring uses external `createAtom`s + `options.atoms` so the table writes through to the atom and the query refetches with no `on[State]Change` plumbing. The canonical Vue example is at `examples/vue/with-tanstack-query/`.
|
| type | composition |
| library | tanstack-table |
| framework | vue |
| library_version | 9.0.0-alpha.48 |
| requires | ["vue/client-to-server","pagination","state-management"] |
| sources | ["examples/vue/with-tanstack-query/src/App.tsx","examples/vue/with-tanstack-query/src/fetchData.ts","examples/vue/with-tanstack-query/src/useService.ts"] |
Compose @tanstack/vue-table with @tanstack/vue-query
Dependencies
pnpm add @tanstack/vue-table @tanstack/vue-query @tanstack/vue-store
@tanstack/vue-store is the recommended way to own server-controlled state slices
(pagination / sorting / global filter). It removes the on[State]Change plumbing — the table
writes through to your atom, and the query refetches because the atom drives queryKey.
Setup — paginated server table
<script setup lang="ts">
import { computed, ref, watchEffect } from 'vue'
import { createAtom, useSelector } from '@tanstack/vue-store'
import { keepPreviousData, useQuery } from '@tanstack/vue-query'
import {
FlexRender,
createColumnHelper,
rowPaginationFeature,
tableFeatures,
useTable,
type PaginationState,
} from '@tanstack/vue-table'
import { fetchPeople } from './fetchData'
type Person = { firstName: string; lastName: string; age: number }
const _features = tableFeatures({ rowPaginationFeature })
const columnHelper = createColumnHelper<typeof _features, Person>()
const columns = columnHelper.columns([
columnHelper.accessor('firstName', { header: 'First' }),
columnHelper.accessor('age', { header: 'Age' }),
])
// 1) Own pagination state in an atom.
const paginationAtom = createAtom<PaginationState>({
pageIndex: 0,
pageSize: 10,
})
const pagination = useSelector(paginationAtom)
// 2) Vue Query: function-returning-options form ensures reactivity over pagination.value.
const dataQuery = useQuery(() => ({
queryKey: ['people', pagination.value],
queryFn: () => fetchPeople(pagination.value),
placeholderData: keepPreviousData,
}))
// 3) Stable empty array — avoid `?? []` inline in options.
const EMPTY: Person[] = []
const tableData = computed<Person[]>(() => dataQuery.data.value?.rows ?? EMPTY)
// 4) Hold last known total so the pager doesn't reset during refetches.
const rowCount = ref(0)
watchEffect(() => {
const next = dataQuery.data.value?.rowCount
if (next != null) rowCount.value = next
})
// 5) Manual pagination. No paginatedRowModel — server paginates.
const table = useTable({
_features,
_rowModels: {},
columns,
data: tableData,
rowCount,
atoms: { pagination: paginationAtom },
manualPagination: true,
// NOTE: no `onPaginationChange` — table.setPageIndex writes through to paginationAtom.
})
</script>
<template>
<div>
<table>
<thead>
<tr v-for="hg in table.getHeaderGroups()" :key="hg.id">
<th v-for="h in hg.headers" :key="h.id">
<FlexRender v-if="!h.isPlaceholder" :header="h" />
</th>
</tr>
</thead>
<tbody>
<tr v-for="row in table.getRowModel().rows" :key="row.id">
<td v-for="cell in row.getAllCells()" :key="cell.id">
<FlexRender :cell="cell" />
</td>
</tr>
</tbody>
</table>
<button
@click="table.previousPage()"
:disabled="!table.getCanPreviousPage()"
>
‹
</button>
<span
>Page {{ pagination.pageIndex + 1 }} of {{ table.getPageCount() }}</span
>
<button @click="table.nextPage()" :disabled="!table.getCanNextPage()">
›
</button>
<span v-if="dataQuery.isFetching.value">Loading…</span>
</div>
</template>
Source: examples/vue/with-tanstack-query/src/App.tsx.
Core Patterns
1. useQuery accepts a getter for reactive keys
const dataQuery = useQuery(() => ({
queryKey: ['people', pagination.value],
queryFn: () => fetchPeople(pagination.value),
placeholderData: keepPreviousData,
}))
const dataQuery = useQuery({
queryKey: ['people', pagination.value],
queryFn: () => fetchPeople(pagination.value),
})
Vue Query's reactive options require the function form. This is unlike React Query's object
form — a common transcription error from copy-pasting React examples.
2. Why external atoms beat state + on*Change for Query
External atoms collapse two flows into one:
table.nextPage()
→ paginationAtom.set( ... )
→ useSelector ref updates
→ queryKey changes
→ useQuery refetches
table.nextPage()
→ onPaginationChange(updater)
→ setLocalPagination(updater)
→ ref updates
→ queryKey changes
→ useQuery refetches
Same end-to-end, but the atom version doesn't need onPaginationChange plumbing and a
sibling component can useSelector(paginationAtom) directly without prop-drilling.
3. Multiple server-controlled slices
const sortingAtom = createAtom<SortingState>([])
const paginationAtom = createAtom<PaginationState>({
pageIndex: 0,
pageSize: 10,
})
const filtersAtom = createAtom<ColumnFiltersState>([])
const sorting = useSelector(sortingAtom)
const pagination = useSelector(paginationAtom)
const filters = useSelector(filtersAtom)
const dataQuery = useQuery(() => ({
queryKey: [
'people',
{
sorting: sorting.value,
pagination: pagination.value,
filters: filters.value,
},
],
queryFn: () =>
fetchPeople({
sorting: sorting.value,
pagination: pagination.value,
filters: filters.value,
}),
placeholderData: keepPreviousData,
}))
const table = useTable({
_features: tableFeatures({
rowSortingFeature,
rowPaginationFeature,
columnFilteringFeature,
}),
_rowModels: {},
columns,
data: computed(() => dataQuery.data.value?.rows ?? EMPTY),
rowCount,
atoms: {
sorting: sortingAtom,
pagination: paginationAtom,
columnFilters: filtersAtom,
},
manualSorting: true,
manualPagination: true,
manualFiltering: true,
})
4. Mutations + invalidateQueries
import { useMutation, useQueryClient } from '@tanstack/vue-query'
const queryClient = useQueryClient()
const addPerson = useMutation({
mutationFn: (person: Person) => savePerson(person),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['people'] })
},
})
The table is a downstream consumer — it has no way to know the server data changed unless
Query tells it. Always invalidate on writes.
Common Mistakes
Omitting manualPagination / manualSorting / manualFiltering (CRITICAL)
useTable({
_features,
_rowModels: {},
columns,
data: tableData,
rowCount,
atoms: { pagination: paginationAtom },
})
The visible symptom is "Page 1 of 1, but I see 10 rows of a 1000-row dataset" — the table
slices the 10-row page locally.
Missing rowCount (CRITICAL)
getPageCount() falls back to Math.ceil(data.length / pageSize) — which is 1 if the
server already paginated. The pager locks at "Page 1 of 1".
Leaving paginatedRowModel registered with manualPagination (HIGH)
_rowModels: {
paginatedRowModel: createPaginatedRowModel()
}
_rowModels: {
}
Forgetting controlled state in queryKey (CRITICAL)
useQuery(() => ({
queryKey: ['people'],
queryFn: () => fetchPeople(pagination.value),
}))
useQuery(() => ({
queryKey: ['people', pagination.value],
queryFn: () => fetchPeople(pagination.value),
}))
Skipping placeholderData: keepPreviousData (HIGH)
Between fetches the table collapses to 0 rows, the row container collapses, scroll position
jumps. keepPreviousData keeps the previous page visible during the refetch.
Passing useQuery({...}) object form with reactive deps (HIGH — Vue-specific)
@tanstack/vue-query requires the function form useQuery(() => ({ ... })) for reactive
options. The object form snapshots once and never re-evaluates queryKey.
Inline ?? [] in data option (MEDIUM)
data: computed(() => dataQuery.data.value?.rows ?? [])
const EMPTY: Person[] = []
const tableData = computed(() => dataQuery.data.value?.rows ?? EMPTY)
Passing the same slice via both state and atoms (HIGH)
atoms wins; state is silently ignored. Pick one mechanism per slice.
Hallucinating React Query in a Vue project (CRITICAL — top AI tell)
import { useQuery, keepPreviousData } from '@tanstack/react-query'
import { useQuery, keepPreviousData } from '@tanstack/vue-query'
Same hooks names, different reactivity model (function-returning-options instead of object).
Hallucinating pre-v9 table APIs (CRITICAL)
useVueTable + getCoreRowModel: getCoreRowModel() is v8. v9 uses useTable +
_features + _rowModels. See tanstack-table/vue/migrate-v8-to-v9.
"API missing" because feature not in _features (CRITICAL — v9-specific)
Server-side pagination still needs rowPaginationFeature in tableFeatures({...}). The
feature gives you table.nextPage / table.setPageIndex / table.getPageCount. The
_rowModels factory is what you drop; the feature stays.
Reimplementing pagination loop manually (CRITICAL — #1 AI tell)
const nextPage = () => {
paginationAtom.set({
...paginationAtom.get(),
pageIndex: paginationAtom.get().pageIndex + 1,
})
}
table.nextPage()
table.previousPage()
table.setPageIndex(0)
table.setPageSize(25)
See Also
tanstack-table/vue/client-to-server — manual modes + rowCount
tanstack-table/vue/compose-with-tanstack-store — external atoms in depth
tanstack-table/vue/table-state — reactivity model
tanstack-table/table-core/pagination — manualPagination semantics