MeldUI

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

SlotPropsDescription
#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:

KeyAction
/ Navigate rows
SpaceSelect/deselect row
EnterActivate row (expand or trigger action)
Home / EndJump to first/last row
PageUp / PageDownPrevious/next page
Ctrl+PageUp / Ctrl+PageDownFirst/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.