Vue 3 data table guide
Use the Vue 3.4+ Composition API with a typed table component. Keep the row type shared between props and options, preserve controller identity during updates, and use the composable when you need direct ownership of the host element.
Install in a Vue 3 application
#Use an existing Vue 3.4+ TypeScript application, such as a Vite Vue project. This guide is checked with wts-data-table 1.1.1 and @wts-data-table/vue 1.0.0. No global Vue plug-in registration is needed.
npm install wts-data-table@1.1.1 @wts-data-table/vue@1.0.0Load styles in the app entry
#Import the stylesheet once in src/main.ts. In Nuxt, add it to the css configuration and keep browser-specific table integration in a client component or ClientOnly boundary. Do not hide the library styles behind a scoped style block.
import 'wts-data-table/styles.css';Define typed rows and editable columns
#Create table-data.ts beside your component. Stable project IDs preserve selection when rows change. Name and budget are editable; status supplies a select filter. The examples below use these three local rows and need no API key.
import type { DataTableOptions } from 'wts-data-table';
export interface Project {
id: string;
name: string;
status: string;
budget: number;
}
export const projects: Project[] = [
{ id: 'p1', name: 'Website redesign', status: 'In progress', budget: 24000 },
{ id: 'p2', name: 'Customer portal', status: 'Planning', budget: 18000 },
{ id: 'p3', name: 'Analytics dashboard', status: 'Complete', budget: 32000 }
];
export const columns: DataTableOptions<Project>['columns'] = [
{ accessor: 'name', header: 'Project', editable: true },
{ accessor: 'status', header: 'Status', filterVariant: 'select' },
{
accessor: 'budget', header: 'Budget', dataType: 'number',
editable: {
type: 'number', min: 0,
validate: value => Number.isFinite(Number(value)) && Number(value) >= 0
? undefined : 'Enter a non-negative budget.'
}
}
];Filter, select, and edit
#The complete component below enables global search, per-column filters, multiple row selection, and cell editing. Double-click a name or budget cell, or focus it and press Enter. Enter commits; Escape cancels. Negative budgets fail validation. These examples save edits in local application state only; a reload restores the sample data.
- showGlobalFilter and columnFilters control the search UI. Use controller.setGlobalFilter() or setColumnFilter() for your own controls.
- selectionMode enables checkboxes. onStateChange receives rowSelection IDs; the selected count here describes explicit selection in this local dataset, not an all-filtered server selection.
- For durable edits, await your authenticated API in onEditCommit before updating framework data. Reject or throw on failure so the table rolls back. Handle onEditError to display a useful message; validate and authorize again on the server.
Connect server-side data
#This is an optional integration recipe, not a hosted API. Create remote-data.ts and implement POST /api/projects/query on your backend. The request contains { state, filtering }; return { rows, rowCount }, where rowCount is the total after filtering, not the current page length. state.pagination.pageIndex is zero-based. The transport validates the response and forwards AbortSignal for cancellation.
- Process filtering, sorting, and pagination on the server when all three manual flags are true. The driver debounces queries, cancels superseded requests, caches results, and ignores stale responses.
- The following remote component is read-only. The data-source driver owns loaded rows; do not replace its stable empty input with the local sample array during rerenders.
- Require authentication and authorization, allowlist column IDs and operators, cap page size, and apply your application’s CSRF protection. Never interpolate untrusted field names into SQL.
- Loading and error phases appear in the status label. Refresh / retry forces a new request. Destroy the driver on unmount to abort requests and release subscriptions.
import type { DataTable } from 'wts-data-table';
import {
createDataTableDataSourceController,
type DataTableDataSourceResult,
type DataTableDataSourceStatus
} from 'wts-data-table/data-source';
import type { Project } from './table-data';
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null;
}
function isProjectPage(value: unknown): value is DataTableDataSourceResult<Project> {
return isRecord(value) && Array.isArray(value.rows)
&& typeof value.rowCount === 'number' && Number.isSafeInteger(value.rowCount)
&& value.rowCount >= value.rows.length
&& value.rows.every(row => isRecord(row)
&& typeof row.id === 'string' && typeof row.name === 'string'
&& typeof row.status === 'string' && typeof row.budget === 'number'
&& Number.isFinite(row.budget));
}
export function connectProjects(
table: DataTable<Project>,
onStatusChange: (status: DataTableDataSourceStatus) => void
) {
return createDataTableDataSourceController<Project>({
table: table.core,
debounceMs: 200,
onStatusChange,
async dataSource({ state, filtering, signal }) {
const response = await fetch('/api/projects/query', {
method: 'POST',
credentials: 'same-origin',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ state, filtering }),
signal
});
if (!response.ok) throw new Error('Project request failed: ' + response.status);
const page: unknown = await response.json();
if (!isProjectPage(page)) throw new Error('Invalid project response');
return page;
}
});
}Use the composable for remote data
#This complete remote component uses useWtsDataTable<Project>() with an element ref. The composable mounts after the DOM exists and destroys the table on unmount; the additional driver is cleaned up explicitly. Use it instead of ProjectTable.vue once the endpoint is ready.
<script setup lang="ts">
import { ref, onBeforeUnmount } from 'vue';
import { useWtsDataTable, type WtsVueDataTableOptions } from '@wts-data-table/vue';
import { columns, type Project } from './table-data';
import { connectProjects } from './remote-data';
const host = ref<HTMLElement | null>(null);
const status = ref('idle');
let remote: ReturnType<typeof connectProjects> | undefined;
const options: WtsVueDataTableOptions<Project> = {
columns, getRowId: row => row.id, ariaLabel: 'Remote projects',
showGlobalFilter: true, columnFilters: 'always', editing: false,
manualFiltering: true, manualSorting: true, manualPagination: true,
initialState: { pagination: { pageSize: 25 } }
};
function disconnect() { remote?.destroy(); remote = undefined; }
function refresh() { void remote?.refresh(true); }
useWtsDataTable<Project>({
host, data: [], options,
onReady(table) {
disconnect();
remote = connectProjects(table, ({ phase }) => { status.value = phase; });
},
onDestroy: disconnect
});
onBeforeUnmount(disconnect);
</script>
<template>
<p role="status">{{ status }}</p>
<button type="button" @click="refresh">Refresh / retry</button>
<div ref="host"></div>
</template>Vue lifecycle, slots, and state
#Keep options as a stable object created once in setup. Store rows in shallowRef and replace rows.value with a new array after edits or network updates. Deep mutation alone does not change the data reference observed by the wrapper.
- createWtsDataTableVue<Project>() is the typed component factory; useWtsDataTable<Project>() is the typed composable for a custom host.
- Use v-model:state on the component when the application owns the state snapshot. Use the onStateChange option or composable callback for derived UI such as the selected count.
- The ready-to-use component accepts scoped slots such as #cell-name, #header-name, #empty, and #loading. The composable gives you the controller rather than the component’s slot bridge.
- Avoid wrapping the controller in a deeply reactive object. Keep it in the shallow reference returned by the composable or access the component’s getController().
Troubleshoot Vue integration
#Check reference identity before changing table configuration.
- Rows do not update: assign a new rows.value array instead of pushing into a shallowRef array.
- Type mismatch with unknown rows: use createWtsDataTableVue<Project>() so the component and options share the same generic type.
- The table resets during an unrelated update: do not build an options object inline in the template or a computed value that changes unnecessarily.
- Nuxt hydration problems: keep browser-only integration inside a client boundary and do not read document or window at module scope.
- Remote table stays empty: check the status, response validation, filtered rowCount, and server query implementation before changing the component.
ProjectTable.vue
#Complete local single-file component. Add table-data.ts from the typed-rows section, load the global stylesheet, and import ProjectTable.vue into a parent template. createWtsDataTableVue<Project>() keeps rows and options typed together.
<script setup lang="ts">
import { ref, shallowRef } from 'vue';
import {
createWtsDataTableVue,
type WtsVueDataTableOptions
} from '@wts-data-table/vue';
import { columns, projects, type Project } from './table-data';
const ProjectGrid = createWtsDataTableVue<Project>();
const rows = shallowRef<readonly Project[]>(projects);
const selected = ref(0);
const options: WtsVueDataTableOptions<Project> = {
columns,
getRowId: row => row.id,
ariaLabel: 'Project delivery',
showGlobalFilter: true,
columnFilters: 'always',
selectionMode: 'multiple',
editing: true,
initialState: { pagination: { pageSize: 10 } },
onStateChange: state => { selected.value = state.rowSelection.length; },
// Local-only edits: replace the array so the wrapper observes the change.
onEditCommit: ({ instance }) => { rows.value = [...instance.getData()]; }
};
</script>
<template>
<p aria-live="polite">{{ selected }} selected</p>
<ProjectGrid :data="rows" :options="options" />
</template>Explore search, selection, editing, and runtime options in the interactive example.
Open the configurable example →Ready to try it with real data?
Explore configurable examples ↗