# Tanstack Table Vue Client To Server

> Client-to-Server Conversion (Vue)

- Skill: `tomevault-io/tanstack-table-vue-client-to-server` (Agent Skill, multi-file: 2 files)
- Install (CLI): `npx skillmds@latest add tomevault-io/tanstack-table-vue-client-to-server`
- Raw SKILL.md: https://api.skillmd.com/api/skills/tomevault-io/tanstack-table-vue-client-to-server/raw
- Safety review: pending (external: skill-scanner PASS, skillspector PASS)
- Works with: Claude Code, Claude.ai, OpenAI Codex
- Category: Web & Frontend
- Author: tomevault-io (https://skillmd.com/u/tomevault-io)
- Updated: 2026-09-17
- Page: https://skillmd.com/skills/tomevault-io/tanstack-table-vue-client-to-server

---


# Client-to-Server Conversion (Vue)

## Dependencies

```bash
pnpm add @tanstack/vue-table @tanstack/vue-store
# Recommended fetch layer:
pnpm add @tanstack/vue-query
```

External atoms (`@tanstack/vue-store`) are recommended for server-managed slices. They cut the
`on[State]Change` plumbing entirely — the table writes to the atom; the query keys on the atom.

## Setup — minimal server-paginated table

```vue
<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 './api'

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('lastName', { header: 'Last' }),
  columnHelper.accessor('age', { header: 'Age' }),
])

// 1) Own pagination in an external atom. Cheap for the table to write through.
const paginationAtom = createAtom<PaginationState>({
  pageIndex: 0,
  pageSize: 10,
})
const pagination = useSelector(paginationAtom)

// 2) Key the query on the atom value. table.setPageIndex(...) writes to the atom
//    → useSelector re-evaluates → useQuery refetches.
const dataQuery = useQuery(() => ({
  queryKey: ['people', pagination.value],
  queryFn: () => fetchPeople(pagination.value),
  placeholderData: keepPreviousData, // avoid "0 rows" flash between pages
}))

const tableData = computed<Person[]>(() => dataQuery.data.value?.rows ?? [])
const rowCount = ref(0)
watchEffect(() => {
  const next = dataQuery.data.value?.rowCount
  if (next != null) rowCount.value = next // keep last known total for pager UI
})

// 3) Manual pagination + `rowCount`. NO paginatedRowModel in `_rowModels` — server paginates.
const table = useTable({
  _features,
  _rowModels: {},
  columns,
  data: tableData,
  rowCount,
  atoms: { pagination: paginationAtom },
  manualPagination: true,
})
</script>
```

Source: `examples/vue/with-tanstack-query/src/App.tsx`, `examples/vue/basic-external-atoms/`.

## Core Patterns

### 1. The `manual*` flag + `_rowModels` drop pair

Pick which slices live server-side and flip the matching `manual*` flag. **Also drop the
matching `_rowModels` factory** — otherwise the table re-processes server-processed rows.

| Server owns | Set                      | Drop from `_rowModels` |
| ----------- | ------------------------ | ---------------------- |
| Pagination  | `manualPagination: true` | `paginatedRowModel`    |
| Sorting     | `manualSorting: true`    | `sortedRowModel`       |
| Filtering   | `manualFiltering: true`  | `filteredRowModel`     |
| Grouping    | `manualGrouping: true`   | `groupedRowModel`      |
| Expanding   | `manualExpanding: true`  | `expandedRowModel`     |

Column visibility / ordering / pinning / row selection are client-side state and stay as-is.

### 2. `rowCount` so `getPageCount()` works

Without `rowCount`, `getPageCount()` falls back to `Math.ceil(data.length / pageSize)` — i.e.
`1` if the server already paginated. The pager locks at "Page 1 of 1".

```ts
useTable({
  _features,
  _rowModels: {},
  columns,
  data: tableData,
  rowCount: dataQuery.data.value?.rowCount, // or a stable ref/computed
  atoms: { pagination: paginationAtom },
  manualPagination: true,
})
```

If `rowCount` isn't immediately available, hold the last known value in a `ref` and update via
`watchEffect` so the pager doesn't reset to 0 during refetches.

### 3. Two state-ownership shapes (pick one per slice)

**External atoms (recommended with Query).** Table writes through to the atom. No
`on[State]Change` needed.

```ts
const paginationAtom = createAtom<PaginationState>({
  pageIndex: 0,
  pageSize: 10,
})
useTable({
  _features,
  _rowModels: {},
  columns,
  data,
  rowCount,
  atoms: { pagination: paginationAtom },
  manualPagination: true,
})
```

**Classic `state` + `on[State]Change` with getters.** Required when migrating from v8 or
integrating with existing Vue ref-based state. Each slice must be a getter so Vue tracks
`.value`.

```ts
const pagination = ref<PaginationState>({ pageIndex: 0, pageSize: 10 })

useTable({
  _features,
  _rowModels: {},
  columns,
  data,
  rowCount,
  state: {
    get pagination() {
      return pagination.value
    },
  },
  onPaginationChange: (u) => {
    pagination.value = typeof u === 'function' ? u(pagination.value) : u
  },
  manualPagination: true,
})
```

**Precedence:** `atoms[slice]` > `state[slice]` > internal `baseAtoms[slice]`. Don't pass the
same slice through both — the atoms wins silently.

### 4. Cache keys must include controlled state

```ts
const sortingAtom = createAtom<SortingState>([])
const paginationAtom = createAtom<PaginationState>({
  pageIndex: 0,
  pageSize: 10,
})
const sorting = useSelector(sortingAtom)
const pagination = useSelector(paginationAtom)

const dataQuery = useQuery(() => ({
  queryKey: [
    'people',
    { sorting: sorting.value, pagination: pagination.value },
  ],
  queryFn: () =>
    fetchPeople({ sorting: sorting.value, pagination: pagination.value }),
  placeholderData: keepPreviousData,
}))
```

If pagination/sort/filter aren't in `queryKey`, Query won't refetch when the user clicks a
pager button — the buttons "do nothing" from the user's POV.

### 5. Mixed client + server features still work

Column visibility, ordering, pinning, and row selection are client state — they don't depend
on the row model and continue to function with `manualPagination`/`manualSorting`/`manualFiltering`.
You can have a server-paginated table where the user pins or hides columns locally.

## Common Mistakes

### Forgetting `manualPagination` / `manualSorting` / `manualFiltering` (CRITICAL)

The table double-processes server-processed rows. If the server returned page 2 of 50,
the table will paginate that 10-row slice again and show "Page 1 of 1".

```ts
// ❌
useTable({
  _features,
  _rowModels: {},
  columns,
  data: serverPage.rows,
  rowCount,
  atoms: { pagination: paginationAtom },
  // missing: manualPagination: true
})
```

### Leaving `paginatedRowModel` registered when server paginates (CRITICAL)

```ts
// ❌ Factory ships for nothing AND the table re-paginates server-sliced data.
_rowModels: {
  paginatedRowModel: createPaginatedRowModel()
}

// ✅
_rowModels: {
}
```

Same applies to `sortedRowModel`, `filteredRowModel`, `groupedRowModel`, `expandedRowModel`
when the server owns the slice.

### Omitting `rowCount` (CRITICAL)

`getPageCount()` returns `1` if the server already paginated. The pager UI locks at
"Page 1 of 1" and users can't navigate.

### Passing `state.pagination` without `onPaginationChange` (CRITICAL)

```ts
// ❌ table.setPageIndex(2) is a no-op — no writeback handler.
const pagination = ref({ pageIndex: 0, pageSize: 10 })
useTable({
  _features,
  _rowModels: {},
  columns,
  data,
  rowCount,
  state: {
    get pagination() {
      return pagination.value
    },
  },
  manualPagination: true,
})

// ✅ Either pair `state` with `on[State]Change`, OR use `atoms`.
```

### Mixing `state.pagination` AND `atoms.pagination` for the same slice (HIGH)

```ts
useTable({
  // ...
  state: {
    get pagination() {
      return localPagination.value
    },
  }, // silently ignored
  onPaginationChange: setLocalPagination, // silently ignored
  atoms: { pagination: paginationAtom }, // wins
})
```

Atoms beat `state`; the `state` plumbing is dead but lingering in the code. Pick one mechanism.

### Forgetting to include controlled state in `queryKey` (CRITICAL)

```ts
// ❌ Never refetches when pagination changes.
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, and scroll
position jumps. `keepPreviousData` keeps the previous page visible during the refetch.

### Passing a raw `ref` to `state.pagination` without a getter (CRITICAL — Vue-specific)

```ts
// ❌ Vue can't track .value changes on the captured ref object.
state: { pagination: pagination }

// ✅
state: { get pagination() { return pagination.value } }
```

### Hand-rolling sort/page state instead of using the API (CRITICAL — #1 AI tell)

```ts
// ❌ Manual state machine.
const pageIndex = ref(0)
const next = () => {
  pageIndex.value++
  refetch()
}

// ✅ Built-ins.
table.nextPage()
table.setPageIndex(0)
table.setSorting([{ id: 'age', desc: true }])
table.setColumnFilters(/* ... */)
```

### "API missing" because the feature isn't in `_features` (CRITICAL — v9-specific)

Server-side pagination still needs `rowPaginationFeature` in `tableFeatures({...})` — that's
what surfaces `table.setPageIndex`, `table.nextPage`, `table.getPageCount`. The factory in
`_rowModels` is what you drop; the feature stays.

```ts
const _features = tableFeatures({ rowPaginationFeature }) // ✅ even with manualPagination
```

## See Also

- `tanstack-table/vue/compose-with-tanstack-query` — the Query-specific wiring
- `tanstack-table/vue/compose-with-tanstack-store` — external atoms in depth
- `tanstack-table/vue/table-state` — getter rule, atom precedence
- `tanstack-table/table-core/pagination` — `manualPagination` / `rowCount` semantics

---
> Source: [TanStack/table](https://github.com/TanStack/table) — distributed by [TomeVault](https://tomevault.io).
<!-- tomevault:4.0:skill_md:2026-05-24 -->

