How I build data tables that don't suck.
A data table sounds simple: fetch some rows, render them. Then product
adds sorting. Then search. Then pagination. Then filters. Then the mobile
designer asks for a card layout. Then someone wants the current filters
to survive a page refresh. By that point your "simple table" is a
400-line component held together by watch calls and optimism.
Here's the architecture I use that keeps all of that manageable — built
on TanStack Vue Table and a composable I call
usePagination.
usePagination — the data layer
The composable owns everything: fetching, pagination state, sorting, search, and URL sync. The table component just renders what it gives back. No data logic leaks into the template.
const { response, state, refetch, setSearch } = usePagination({
id: "vehicles", // namespaces URL query params
url: "/api/vehicles",
limit: 20,
sortBy: "created_at",
sortDirection: "desc",
params: (state) => ({
status: statusFilter.value,
search: state.search,
}),
resetOn: [statusFilter], // resets to page 1 when filter changes
});
// response = ComputedRef<Vehicle[]>
// state = { page, limit, search, sorting } response is a computed array of your data.
state has the current page, limit, search, and sorting.
That's all the table needs to know.
URL state sync
This is the part most table implementations skip, and the one
users notice most. Every state change — page, search, sort, filters —
writes to the URL as query params. The id prefix namespaces
them so multiple tables on the same page don't collide.
// State automatically writes to URL:
// /vehicles?vehicles_page=2&vehicles_limit=20&vehicles_q=addis
// Custom params get namespaced too:
// /vehicles?vehicles__status=active
// On mount, state is restored from URL — the user lands
// exactly where they left off after a refresh or share. This takes ten minutes to add and makes the app feel polished in a way people can't quite name — they just know it works how they expect.
Column definitions
Columns are plain objects. The field prop accepts either
a string key or a function — simple cases stay simple, computed values
don't need a separate computed property just to format a full name.
const columns: TableColumn<Vehicle>[] = [
{
key: "plate",
label: "Plate Number",
field: "plate_number", // string key
sortable: true,
sort_key: "plate_number",
},
{
key: "driver",
label: "Driver",
field: (row) => row.driver.first_name + " " + row.driver.last_name, // function
},
{
key: "status",
label: "Status",
cellAlign: "center",
},
]; Custom cell rendering
For cells that need more than text — status badges, action buttons,
formatted dates — I use named slots keyed by column id.
No render props, no column-level render functions. Just slots.
Vue is good at slots. I use them.
<Table :columns="columns" :data="response">
<template #cell-status="{ row }">
<span :class="['badge', 'badge--' + row.status]">
{{ row.status }}
</span>
</template>
<template #cell-actions="{ row }">
<button @click="openDetail(row.id)">View</button>
</template>
</Table> The flexible response parser
Different APIs return arrays under different keys. Rather than normalize
every endpoint or write a custom parser per table, I handle it once
inside usePagination. Pragmatic? Yes. Perfect? No. Does it
mean I've never had to ask a backend engineer to change their response
shape just to fit my table? Also yes.
function parseResponse<T>(data: any): T[] {
if (Array.isArray(data)) return data;
const keys = [
"data", "result", "items", "results",
"docs", "documents", "shipments", "vehicles"
];
for (const key of keys) {
if (Array.isArray(data?.[key])) return data[key];
}
return [];
} Mobile: the card layout
On small screens the table switches to a card grid automatically. Each column can specify its small-screen span and alignment independently of the desktop layout. One column can be pinned to the top-right corner of each card — useful for status badges.
// Column config for the mobile card layout:
{
key: "plate",
label: "Plate",
on_sm_screen_column_span: 2, // 2 of 3 grid columns
on_sm_screen_row_alignment: "start", // aligns to top of card
}
// One column can be pinned to the top-right corner of the card:
{
key: "status",
top_right: true,
} Wiring it all together
A full page with search, sorting, pagination, URL sync, and a custom status cell ends up looking like this. The page component describes what to show — not how to manage it. That's the goal.
<script setup lang="ts">
const { response, state, setSearch, refetch } = usePagination({
id: "drivers",
url: "/api/drivers",
sortBy: "created_at",
sortDirection: "desc",
});
const columns: TableColumn<Driver>[] = [
{ key: "name", label: "Name", field: "full_name", sortable: true },
{ key: "phone", label: "Phone", field: "phone_number" },
{ key: "status", label: "Status", cellAlign: "center" },
];
</script>
<template>
<Table
:columns="columns"
:data="response"
:state="state"
@search="setSearch"
@refetch="refetch"
>
<template #cell-status="{ row }">
<StatusBadge :status="row.status" />
</template>
</Table>
</template> What I'd do differently
The response parser is a hack and I know it. The right solution is a typed API client that returns consistent shapes. I keep meaning to build one. I keep shipping features instead. Classic.