Advanced Data Tables
Build enterprise-grade data tables with filtering, sorting, pagination, inline editing, bulk actions, and export capabilities.
Why Advanced Tables
- ✅ Handle large datasets efficiently
- ✅ Provide powerful filtering and search
- ✅ Enable quick inline editing
- ✅ Support bulk operations
- ✅ Export data in various formats
Server-Side Pagination & Sorting
Composable for Table State
// app/composables/useDataTable.ts
export interface TableState {
page: number
pageSize: number
sortBy: string | null
sortOrder: 'asc' | 'desc'
filters: Record<string, any>
}
export function useDataTable<T>(
endpoint: string,
initialState: Partial<TableState> = {}
) {
const state = ref<TableState>({
page: 1,
pageSize: 10,
sortBy: null,
sortOrder: 'asc',
filters: {},
...initialState
})
const data = ref<T[]>([])
const total = ref(0)
const loading = ref(false)
const totalPages = computed(() => Math.ceil(total.value / state.value.pageSize))
async function fetchData() {
loading.value = true
try {
const params = new URLSearchParams({
page: state.value.page.toString(),
pageSize: state.value.pageSize.toString(),
...(state.value.sortBy && {
sortBy: state.value.sortBy,
sortOrder: state.value.sortOrder
}),
...state.value.filters
})
const response = await $fetch<{ data: T[], total: number }>(
`${endpoint}?${params}`
)
data.value = response.data
total.value = response.total
} finally {
loading.value = false
}
}
function setPage(page: number) {
state.value.page = page
fetchData()
}
function setSort(column: string) {
if (state.value.sortBy === column) {
state.value.sortOrder = state.value.sortOrder === 'asc' ? 'desc' : 'asc'
} else {
state.value.sortBy = column
state.value.sortOrder = 'asc'
}
state.value.page = 1
fetchData()
}
function setFilters(filters: Record<string, any>) {
state.value.filters = filters
state.value.page = 1
fetchData()
}
// Fetch on mount
onMounted(() => fetchData())
return {
data: readonly(data),
total: readonly(total),
loading: readonly(loading),
state: readonly(state),
totalPages,
setPage,
setSort,
setFilters,
refresh: fetchData
}
}
Server-Side API Handler
// server/api/users/index.get.ts
import type { H3Event } from 'h3'
export default defineEventHandler(async (event: H3Event) => {
const query = getQuery(event)
const page = parseInt(query.page as string) || 1
const pageSize = parseInt(query.pageSize as string) || 10
const sortBy = query.sortBy as string | undefined
const sortOrder = query.sortOrder as 'asc' | 'desc' || 'asc'
// Build filters
const filters: any = {}
if (query.search) {
filters.OR = [
{ name: { contains: query.search, mode: 'insensitive' } },
{ email: { contains: query.search, mode: 'insensitive' } }
]
}
if (query.role) {
filters.role = query.role
}
if (query.status) {
filters.status = query.status
}
// Fetch data with pagination
const [data, total] = await Promise.all([
db.user.findMany({
where: filters,
skip: (page - 1) * pageSize,
take: pageSize,
orderBy: sortBy ? { [sortBy]: sortOrder } : { createdAt: 'desc' }
}),
db.user.count({ where: filters })
])
return {
data,
total,
page,
pageSize,
totalPages: Math.ceil(total / pageSize)
}
})
Multi-Criteria Filtering
Filter Component
<script setup lang="ts">
interface FilterState {
search: string
role: string
status: string
dateFrom: string
dateTo: string
}
const emit = defineEmits<{
filter: [filters: FilterState]
}>()
const filters = ref<FilterState>({
search: '',
role: '',
status: '',
dateFrom: '',
dateTo: ''
})
const roles = ['admin', 'user', 'guest']
const statuses = ['active', 'inactive', 'pending']
function applyFilters() {
const activeFilters = Object.fromEntries(
Object.entries(filters.value).filter(([_, v]) => v !== '')
)
emit('filter', activeFilters as FilterState)
}
function resetFilters() {
filters.value = {
search: '',
role: '',
status: '',
dateFrom: '',
dateTo: ''
}
applyFilters()
}
</script>
<template>
<div class="filters">
<input
v-model="filters.search"
type="text"
placeholder="Search by name or email..."
@input="applyFilters"
/>
<select v-model="filters.role" @change="applyFilters">
<option value="">All Roles</option>
<option v-for="role in roles" :key="role" :value="role">
{{ role }}
</option>
</select>
<select v-model="filters.status" @change="applyFilters">
<option value="">All Statuses</option>
<option v-for="status in statuses" :key="status" :value="status">
{{ status }}
</option>
</select>
<input
v-model="filters.dateFrom"
type="date"
placeholder="From"
@change="applyFilters"
/>
<input
v-model="filters.dateTo"
type="date"
placeholder="To"
@change="applyFilters"
/>
<button @click="resetFilters">Reset</button>
</div>
</template>
Data Table Component
<script setup lang="ts">
interface User {
id: string
name: string
email: string
role: string
status: string
createdAt: string
}
const {
data: users,
total,
loading,
state,
totalPages,
setPage,
setSort,
setFilters,
refresh
} = useDataTable<User>('/api/users')
const selectedRows = ref<Set<string>>(new Set())
function toggleRow(id: string) {
if (selectedRows.value.has(id)) {
selectedRows.value.delete(id)
} else {
selectedRows.value.add(id)
}
}
function toggleAll() {
if (selectedRows.value.size === users.value.length) {
selectedRows.value.clear()
} else {
users.value.forEach(u => selectedRows.value.add(u.id))
}
}
const allSelected = computed(() =>
users.value.length > 0 && selectedRows.value.size === users.value.length
)
</script>
<template>
<div class="table-container">
<div class="table-header">
<h2>Users ({{ total }})</h2>
<button @click="refresh">Refresh</button>
</div>
<TableFilters @filter="setFilters" />
<div v-if="selectedRows.size > 0" class="bulk-actions">
<span>{{ selectedRows.size }} selected</span>
<button @click="bulkDelete">Delete</button>
<button @click="bulkExport">Export</button>
</div>
<table>
<thead>
<tr>
<th>
<input
type="checkbox"
:checked="allSelected"
@change="toggleAll"
/>
</th>
<th @click="setSort('name')">
Name
<SortIcon :active="state.sortBy === 'name'" :order="state.sortOrder" />
</th>
<th @click="setSort('email')">
Email
<SortIcon :active="state.sortBy === 'email'" :order="state.sortOrder" />
</th>
<th @click="setSort('role')">Role</th>
<th @click="setSort('status')">Status</th>
<th @click="setSort('createdAt')">Created</th>
<th>Actions</th>
</tr>
</thead>
<tbody v-if="!loading">
<tr v-for="user in users" :key="user.id">
<td>
<input
type="checkbox"
:checked="selectedRows.has(user.id)"
@change="toggleRow(user.id)"
/>
</td>
<td>{{ user.name }}</td>
<td>{{ user.email }}</td>
<td>{{ user.role }}</td>
<td>
<StatusBadge :status="user.status" />
</td>
<td>{{ formatDate(user.createdAt) }}</td>
<td>
<button @click="editUser(user)">Edit</button>
<button @click="deleteUser(user.id)">Delete</button>
</td>
</tr>
</tbody>
<tbody v-else>
<tr>
<td colspan="7" class="loading">Loading...</td>
</tr>
</tbody>
</table>
<Pagination
:current-page="state.page"
:total-pages="totalPages"
@change="setPage"
/>
</div>
</template>
Inline Editing
<script setup lang="ts">
interface EditableUser extends User {
_editing?: boolean
}
const users = ref<EditableUser[]>([])
const editingId = ref<string | null>(null)
const editForm = ref<Partial<User>>({})
function startEdit(user: User) {
editingId.value = user.id
editForm.value = { ...user }
}
function cancelEdit() {
editingId.value = null
editForm.value = {}
}
async function saveEdit() {
if (!editingId.value) return
try {
await $fetch(`/api/users/${editingId.value}`, {
method: 'PATCH',
body: editForm.value
})
const index = users.value.findIndex(u => u.id === editingId.value)
if (index !== -1) {
users.value[index] = { ...users.value[index], ...editForm.value }
}
cancelEdit()
} catch (error) {
console.error('Failed to save:', error)
}
}
</script>
<template>
<tr v-for="user in users" :key="user.id">
<td v-if="editingId === user.id">
<input v-model="editForm.name" />
</td>
<td v-else>{{ user.name }}</td>
<td v-if="editingId === user.id">
<input v-model="editForm.email" type="email" />
</td>
<td v-else>{{ user.email }}</td>
<td>
<template v-if="editingId === user.id">
<button @click="saveEdit">Save</button>
<button @click="cancelEdit">Cancel</button>
</template>
<template v-else>
<button @click="startEdit(user)">Edit</button>
</template>
</td>
</tr>
</template>
CSV Export
// app/composables/useTableExport.ts
export function useTableExport() {
function exportToCSV<T extends Record<string, any>>(
data: T[],
filename: string,
columns: { key: keyof T; label: string }[]
) {
// Create CSV content
const headers = columns.map(col => col.label).join(',')
const rows = data.map(row =>
columns.map(col => {
const value = row[col.key]
// Escape quotes and wrap in quotes if contains comma
const escaped = String(value).replace(/"/g, '""')
return escaped.includes(',') ? `"${escaped}"` : escaped
}).join(',')
)
const csv = [headers, ...rows].join('\n')
// Download
const blob = new Blob([csv], { type: 'text/csv' })
const url = URL.createObjectURL(blob)
const link = document.createElement('a')
link.href = url
link.download = `${filename}.csv`
link.click()
URL.revokeObjectURL(url)
}
async function exportAllToCSV(endpoint: string, filename: string) {
const { data } = await $fetch<{ data: any[] }>(
`${endpoint}?pageSize=10000`
)
exportToCSV(data, filename, [
{ key: 'name', label: 'Name' },
{ key: 'email', label: 'Email' },
{ key: 'role', label: 'Role' }
])
}
return {
exportToCSV,
exportAllToCSV
}
}
Virtual Scrolling for Large Datasets
<script setup lang="ts">
import { useVirtualList } from '@vueuse/core'
const allData = ref<User[]>([])
const { list, containerProps, wrapperProps } = useVirtualList(allData, {
itemHeight: 50,
overscan: 10
})
</script>
<template>
<div v-bind="containerProps" class="virtual-container">
<div v-bind="wrapperProps">
<div
v-for="{ data: user, index } in list"
:key="user.id"
class="virtual-row"
>
<span>{{ user.name }}</span>
<span>{{ user.email }}</span>
</div>
</div>
</div>
</template>
<style scoped>
.virtual-container {
height: 600px;
overflow-y: auto;
}
.virtual-row {
height: 50px;
display: flex;
align-items: center;
border-bottom: 1px solid #eee;
}
</style>
Bulk Actions
// app/composables/useBulkActions.ts
export function useBulkActions<T extends { id: string }>(
endpoint: string
) {
const processing = ref(false)
async function bulkDelete(ids: string[]) {
processing.value = true
try {
await $fetch(`${endpoint}/bulk`, {
method: 'DELETE',
body: { ids }
})
} finally {
processing.value = false
}
}
async function bulkUpdate(ids: string[], data: Partial<T>) {
processing.value = true
try {
await $fetch(`${endpoint}/bulk`, {
method: 'PATCH',
body: { ids, data }
})
} finally {
processing.value = false
}
}
return {
processing: readonly(processing),
bulkDelete,
bulkUpdate
}
}
Best Practices
✅ Do's
- ✅ Implement server-side pagination for large datasets
- ✅ Debounce search inputs to reduce API calls
- ✅ Use virtual scrolling for 1000+ rows
- ✅ Show loading states during operations
- ✅ Validate inline edits before saving
- ✅ Provide clear feedback for bulk actions
- ✅ Cache table state in URL for bookmarking
❌ Don'ts
- ❌ Don't load all data at once for large datasets
- ❌ Don't forget to handle errors gracefully
- ❌ Don't allow editing without validation
- ❌ Don't export without row limits
- ❌ Don't forget accessibility (keyboard navigation)
Advanced Features
Column Visibility Toggle
const columns = ref([
{ key: 'name', label: 'Name', visible: true },
{ key: 'email', label: 'Email', visible: true },
{ key: 'role', label: 'Role', visible: true },
{ key: 'status', label: 'Status', visible: false }
])
const visibleColumns = computed(() =>
columns.value.filter(col => col.visible)
)
Persist Table State
// Auto-save to localStorage
watch(state, (newState) => {
localStorage.setItem('tableState', JSON.stringify(newState))
}, { deep: true })
// Restore on mount
onMounted(() => {
const saved = localStorage.getItem('tableState')
if (saved) {
Object.assign(state.value, JSON.parse(saved))
}
})
Advanced data tables are essential for enterprise applications. Implement these patterns to provide powerful, user-friendly data management.