Skip to content

The client boundary

The table is interactive, so it is a client component. The page around it does not have to be: fetch on the server, render the shell there, and pass the rows across.

// app/people/page.tsx (a server component)
import { PeopleTable } from './PeopleTable';

export default async function PeoplePage({ searchParams }) {
  const data = await getPeople(searchParams);
  return <PeopleTable initialData={data} />;
}
// app/people/PeopleTable.tsx
'use client';
import { DataTable } from '@kingtech/react-tablekit';

export function PeopleTable({ initialData }) {
  return <DataTable data={initialData.items} rowCount={initialData.total} columns={columns} />;
}

Columns contain functions, so they cannot cross the server-client boundary as props. Define them in the client module.

The stylesheet

Import it once, in the root layout:

import '@kingtech/react-tablekit/styles.css';

That is plain CSS, so it works with the App Router's own CSS handling without a transform. It carries the default look; a preset is a second import on top of it, and only when you want one:

import '@kingtech/react-tablekit/presets/classic.css';

Rendering without a jump

On the server there is no viewport, so the table renders at ssrBreakpoint, which is lg by default. If most of your readers are on phones, set it to sm and the server-rendered layout will match what they hydrate into:

<DataTable responsive={{ ssrBreakpoint: 'sm' }} data={rows} columns={columns} />

This is the difference between a first paint that settles and one that visibly re-lays-out.

State in the URL

useSearchParams, useRouter and usePathname give useRouterSync what it needs, so filters and paging become real navigations that a server component can read. It works on a table instance, so build one with useDataTable and render it with <DataTable table={table} />:

'use client';
import { usePathname, useRouter, useSearchParams } from 'next/navigation';
import { DataTable, useDataTable, useRouterSync } from '@kingtech/react-tablekit';

export function PeopleTable({ data }: { data: Person[] }) {
  const searchParams = useSearchParams();
  const router = useRouter();
  const pathname = usePathname();
  const table = useDataTable({ data, columns, getRowId: (p) => p.id });

  useRouterSync(table, {
    getSearch: () => searchParams.toString(),
    setSearch: (search, mode) => router[mode](pathname + search, { scroll: false }),
  });

  return <DataTable table={table} aria-label="People" />;
}

With scroll: false the page does not jump to the top when a filter changes. The table asks for replace by default, so typing in the search box does not fill the history; useRouterSync(table, adapter, { url: { mode: 'push' } }) makes every change a history entry.

Server actions

A bulk action is an ordinary server action. Pass the selection query, not a list of ids, so it still works when the reader selected everything matching:

renderBulkActions={({ selectionQuery }) => (
  <button onClick={() => archivePeople(selectionQuery)}>Archive</button>
)}

Limitations

  • The table cannot be a server component. It has state and event handlers; there is no read-only build of it.
  • Columns are not serializable. cell, format and accessorFn are functions, so the column list has to be defined on the client side of the boundary.
  • Streaming and the first paint. With loading.tsx and a suspended fetch, decide whether the table's own skeleton or the route's fallback is the one the reader should see; showing both is worse than either.