Data Table: Server-Side
Server-side operation params, response format, URL state persistence, and the v-model emit contract.
How Server-Side Works
The DataTable runs in fully manual mode — TanStack never sorts, filters, or paginates. The parent owns state via three v-model bindings (sorting, filters, pagination) and triggers fetches whenever the merged state changes. Use useDataTableController to bundle the three refs and apply the page-reset rule with flush: 'sync'.
import { useDataTableController, tableStateToServerParams } from '@meldui/vue'
const { sorting, filters, pagination, state } = useDataTableController({ pageSize: 20 })
async function fetchPage() {
const params = tableStateToServerParams(state.value)
const response = await api.get('/users', { params })
data.value = response.data
pageCount.value = response.meta.total_pages
totalRows.value = response.meta.total
}
watch(state, fetchPage, { deep: true })
fetchPage()
Server Params Helper
tableStateToServerParams converts the merged state to a standard REST shape:
import { tableStateToServerParams } from '@meldui/vue'
import type { ServerSideTableParams } from '@meldui/vue'
const params: ServerSideTableParams = tableStateToServerParams(state.value)
// params = { page, per_page, sort_by, sort_order, filters }
ServerSideTableParams
interface ServerSideTableParams {
page: number
per_page: number
sort_by?: string
sort_order?: 'asc' | 'desc'
filters?: Record<string, ServerFilterValue>
}
Server Response Format
interface ServerSideTableResponse<T> {
data: T[]
meta: {
current_page: number
per_page: number
total: number
total_pages: number
}
}
Single-Fetch Guarantee
useDataTableController applies flush: 'sync' watchers internally so that when a filter or sort change auto-resets pageIndex, the parent observes one state mutation per user action. Without flush: 'sync', every filter change would trigger two fetches — once with the new filter + old pageIndex, then again after the reset.
If you skip the composable, you must replicate the rule:
watch(
filters,
() => {
pagination.value = { ...pagination.value, pageIndex: 0 }
},
{ deep: true, flush: 'sync' },
)
watch(
sorting,
() => {
pagination.value = { ...pagination.value, pageIndex: 0 }
},
{ deep: true, flush: 'sync' },
)
URL State Persistence
Seed the controller from URL query params and persist back on every change:
import { useRoute, useRouter } from 'vue-router'
const route = useRoute()
const router = useRouter()
const { sorting, filters, pagination, state } = useDataTableController({
pageSize: Number(route.query.size) || 20,
initialSorting: parseSorting(route.query.sort),
initialFilters: parseFilters(route.query.f),
initialPagination: {
pageIndex: Number(route.query.page) || 0,
pageSize: Number(route.query.size) || 20,
},
})
watch(
state,
(s) => {
router.replace({ query: serializeState(s) })
},
{ deep: true },
)
watch(state, fetchPage, { deep: true })
Slots
| Slot | Props | Description |
|---|---|---|
#toolbar | { table, loading } | Replace entire toolbar |
#toolbar-start | { table } | Add content before filter row |
#toolbar-end | { table } | Add content after filter row |
#empty | { message, columns } | Custom empty state |
#error | { error, retry } | Custom error state |
#pagination | { pagination, pageCount, totalRows } | Custom pagination footer |
#expanded-row | { row } | Row expansion content |
#cell-[columnId] | { cell, row, value } | Custom cell for a specific column |
Keyboard Navigation
Enable with enableKeyboardNavigation:
| Key | Action |
|---|---|
↑ / ↓ | Navigate rows |
Space | Select/deselect row |
Enter | Activate row (expand or trigger action) |
Home / End | Jump to first/last row |
PageUp / PageDown | Previous/next page |
Ctrl+PageUp / Ctrl+PageDown | First/last page |
Column Resizing
<DataTable enable-column-resizing column-resize-mode="onChange" />
Column Visibility
<DataTable enable-column-hiding />
Shows a dropdown in the toolbar to toggle column visibility.