Building a Fast, Accessible React Data Table with Pagination

Build a fast, accessible React data table with robust pagination—client and server examples, TypeScript components, a11y, and performance tips.

ASOasis
10 min read
Building a Fast, Accessible React Data Table with Pagination

Image used for representation purposes only.

Overview

A paginated data table is a backbone UI pattern in many React applications: admin dashboards, analytics, CRMs, and any list-heavy product benefits from it. Done well, pagination keeps pages fast, reduces network payloads, improves accessibility, and gives users precise control over navigation.

This guide walks through building a reusable, accessible React pagination component and integrating it with a data table for both client-side and server-side pagination. You’ll also see performance tips, testing ideas, and common pitfalls to avoid.

UX and requirements

Before writing code, clarify the experience:

  • Accessibility: Semantic table markup, operable pagination controls, screen reader announcements, visible focus states.
  • Predictability: Consistent page size options, disabled prev/next at boundaries, and clear page counts.
  • Performance: Memoized slices for client-side data; keep-previous-data and cancelation for server-side.
  • Flexibility: A Pagination component that works standalone and with any table library.

Functional requirements for Pagination:

  • Controlled API: page (1-based), pageSize, totalItems, onPageChange.
  • Optional pageSize selector: onPageSizeChange, pageSizeOptions.
  • Compact layout with ellipses when many pages exist.
  • Keyboard support and ARIA labeling.

Data contracts you’ll need

For server-side pagination, agree on an API contract like:

  • Request: GET /users?page=1&pageSize=20
  • Response: { items: User[]; total: number; page: number; pageSize: number }

This gives the UI both the data and the total count to compute pages.

Pagination range utility (with ellipses)

We’ll start with a pure function that returns a mixed array of numbers and a special ellipsis token to drive the UI.

// paginationRange.ts
export const ELLIPSIS = '…' as const;

type PageItem = number | typeof ELLIPSIS;

interface RangeArgs {
  totalItems: number;
  pageSize: number;
  page: number; // 1-based
  siblingCount?: number; // how many pages to show on each side of current
}

export function getPaginationRange({ totalItems, pageSize, page, siblingCount = 1 }: RangeArgs): PageItem[] {
  const totalPages = Math.max(1, Math.ceil(totalItems / pageSize));
  const current = Math.min(Math.max(page, 1), totalPages);

  const totalNumbers = siblingCount * 2 + 5; // first, last, current, two ellipses
  if (totalNumbers >= totalPages) {
    return Array.from({ length: totalPages }, (_, i) => i + 1);
  }

  const leftSibling = Math.max(current - siblingCount, 1);
  const rightSibling = Math.min(current + siblingCount, totalPages);

  const showLeftEllipsis = leftSibling > 2;
  const showRightEllipsis = rightSibling < totalPages - 1;

  const range: PageItem[] = [];
  range.push(1);

  if (showLeftEllipsis) range.push(ELLIPSIS);

  for (let i = leftSibling; i <= rightSibling; i++) {
    if (i !== 1 && i !== totalPages) range.push(i);
  }

  if (showRightEllipsis) range.push(ELLIPSIS);

  if (totalPages > 1) range.push(totalPages);
  return range;
}

Accessible Pagination component (React + TypeScript)

The component renders Prev/Next, a numeric list with ellipses, and an optional page-size selector. It announces page changes to assistive tech.

// Pagination.tsx
import React, { useEffect, useMemo, useRef } from 'react';
import { getPaginationRange, ELLIPSIS } from './paginationRange';

export interface PaginationProps {
  page: number; // 1-based
  pageSize: number;
  totalItems: number;
  onPageChange: (page: number) => void;
  onPageSizeChange?: (size: number) => void;
  pageSizeOptions?: number[];
  siblingCount?: number;
  ariaLabel?: string; // e.g., "Table pagination"
  className?: string;
}

export function Pagination({
  page,
  pageSize,
  totalItems,
  onPageChange,
  onPageSizeChange,
  pageSizeOptions = [10, 20, 50, 100],
  siblingCount = 1,
  ariaLabel = 'Pagination',
  className,
}: PaginationProps) {
  const totalPages = Math.max(1, Math.ceil(totalItems / pageSize));
  const safePage = Math.min(Math.max(page, 1), totalPages);
  const range = useMemo(
    () => getPaginationRange({ totalItems, pageSize, page: safePage, siblingCount }),
    [totalItems, pageSize, safePage, siblingCount]
  );

  const liveRef = useRef<HTMLDivElement>(null);
  useEffect(() => {
    if (liveRef.current) {
      liveRef.current.textContent = `Page ${safePage} of ${totalPages}`;
    }
  }, [safePage, totalPages]);

  const goto = (p: number) => {
    const clamped = Math.min(Math.max(p, 1), totalPages);
    if (clamped !== safePage) onPageChange(clamped);
  };

  return (
    <nav className={className} aria-label={ariaLabel} role="navigation">
      <div aria-live="polite" aria-atomic="true" className="sr-only" ref={liveRef} />

      <ul className="pagination" role="list">
        <li>
          <button
            type="button"
            className="page-btn prev"
            onClick={() => goto(safePage - 1)}
            disabled={safePage <= 1}
            aria-label="Previous page"
          >
            ‹ Prev
          </button>
        </li>

        {range.map((item, idx) => (
          <li key={`${item}-${idx}`}>
            {item === ELLIPSIS ? (
              <span className="ellipsis" aria-hidden>
                {ELLIPSIS}
              </span>
            ) : (
              <button
                type="button"
                className={`page-btn number ${item === safePage ? 'active' : ''}`}
                aria-current={item === safePage ? 'page' : undefined}
                onClick={() => goto(item as number)}
              >
                {item}
              </button>
            )}
          </li>
        ))}

        <li>
          <button
            type="button"
            className="page-btn next"
            onClick={() => goto(safePage + 1)}
            disabled={safePage >= totalPages}
            aria-label="Next page"
          >
            Next ›
          </button>
        </li>

        {onPageSizeChange && (
          <li className="page-size">
            <label className="sr-only" htmlFor="page-size-select">Rows per page</label>
            <select
              id="page-size-select"
              value={pageSize}
              onChange={(e) => onPageSizeChange(Number(e.target.value))}
              aria-label="Rows per page"
            >
              {pageSizeOptions.map((opt) => (
                <option key={opt} value={opt}>{opt}</option>
              ))}
            </select>
          </li>
        )}
      </ul>
    </nav>
  );
}

Minimal styles to keep it usable:

/* pagination.css */
.pagination { display: flex; gap: .25rem; align-items: center; list-style: none; padding: 0; }
.page-btn { border: 1px solid #d0d7de; background: #fff; padding: .25rem .5rem; border-radius: 6px; cursor: pointer; }
.page-btn[disabled] { opacity: .5; cursor: not-allowed; }
.page-btn.active { background: #0969da; color: #fff; border-color: #0969da; }
.ellipsis { padding: 0 .5rem; color: #6e7781; }
.page-size select { margin-left: .5rem; }
.sr-only { position: absolute; left: -10000px; top: auto; width: 1px; height: 1px; overflow: hidden; }

A lightweight, semantic DataTable

A reusable, generic table that accepts columns and data. Keep it unopinionated so you can compose features.

// DataTable.tsx
import React from 'react';

type ReactNode = React.ReactNode;

export interface Column<T> {
  key: keyof T | string;
  header: string;
  width?: string;
  render?: (row: T) => ReactNode;
}

export interface DataTableProps<T> {
  columns: Column<T>[];
  data: T[];
  loading?: boolean;
  getRowKey?: (row: T, idx: number) => string | number;
  emptyState?: ReactNode;
}

export function DataTable<T>({ columns, data, loading, getRowKey, emptyState }: DataTableProps<T>) {
  return (
    <div className="table-wrapper" role="region" aria-label="Data table">
      <table className="table" role="table">
        <thead>
          <tr>
            {columns.map((c) => (
              <th key={String(c.key)} style={{ width: c.width }}>{c.header}</th>
            ))}
          </tr>
        </thead>
        <tbody>
          {loading ? (
            <tr><td colSpan={columns.length}>Loading…</td></tr>
          ) : data.length === 0 ? (
            <tr><td colSpan={columns.length}>{emptyState ?? 'No data'}</td></tr>
          ) : (
            data.map((row, i) => (
              <tr key={getRowKey?.(row, i) ?? i}>
                {columns.map((c) => (
                  <td key={String(c.key)}>
                    {c.render ? c.render(row) : (row as any)[c.key]}
                  </td>
                ))}
              </tr>
            ))
          )}
        </tbody>
      </table>
    </div>
  );
}

Basic styles:

.table { width: 100%; border-collapse: collapse; }
th, td { border-bottom: 1px solid #eaeef2; padding: .5rem; text-align: left; }
th { background: #f6f8fa; font-weight: 600; }
.table-wrapper { overflow: auto; max-width: 100%; }

Client-side pagination example

If you already have the full dataset in memory, slice it per page. This is ideal for small-to-medium lists (e.g., < 5–10k rows). For large lists, consider virtualization.

// ClientSidePage.tsx
import React, { useMemo, useState } from 'react';
import { DataTable } from './DataTable';
import { Pagination } from './Pagination';

interface User { id: number; name: string; email: string; }

const allUsers: User[] = Array.from({ length: 347 }, (_, i) => ({
  id: i + 1,
  name: `User ${i + 1}`,
  email: `user${i + 1}@example.com`,
}));

export default function ClientSidePage() {
  const [page, setPage] = useState(1);
  const [pageSize, setPageSize] = useState(20);

  const start = (page - 1) * pageSize;
  const end = start + pageSize;

  const pageData = useMemo(() => allUsers.slice(start, end), [start, end]);

  return (
    <section>
      <DataTable<User>
        columns=[
          { key: 'id', header: 'ID', width: '80px' },
          { key: 'name', header: 'Name' },
          { key: 'email', header: 'Email' },
        ]
        data={pageData}
      />

      <Pagination
        page={page}
        pageSize={pageSize}
        totalItems={allUsers.length}
        onPageChange={setPage}
        onPageSizeChange={(size) => { setPageSize(size); setPage(1); }}
        ariaLabel="Users table pagination"
      />
    </section>
  );
}

Server-side pagination with React Query

For large datasets or when the backend owns sorting/filtering, fetch page-by-page. Use keepPreviousData to prevent UI flicker and allow fast Next/Prev.

// ServerSidePage.tsx
import React, { useState } from 'react';
import { useQuery } from '@tanstack/react-query';
import { DataTable } from './DataTable';
import { Pagination } from './Pagination';

interface User { id: number; name: string; email: string; }
interface UsersResponse { items: User[]; total: number; page: number; pageSize: number; }

async function fetchUsers(page: number, pageSize: number): Promise<UsersResponse> {
  const res = await fetch(`/api/users?page=${page}&pageSize=${pageSize}`);
  if (!res.ok) throw new Error('Failed to fetch');
  return res.json();
}

export default function ServerSidePage() {
  const [page, setPage] = useState(1);
  const [pageSize, setPageSize] = useState(20);

  const { data, isLoading, isFetching } = useQuery({
    queryKey: ['users', page, pageSize],
    queryFn: () => fetchUsers(page, pageSize),
    keepPreviousData: true,
  });

  return (
    <section>
      <DataTable<User>
        columns=[
          { key: 'id', header: 'ID', width: '80px' },
          { key: 'name', header: 'Name' },
          { key: 'email', header: 'Email' },
        ]
        data={data?.items ?? []}
        loading={isLoading || isFetching}
        emptyState="No users found"
      />

      <Pagination
        page={page}
        pageSize={pageSize}
        totalItems={data?.total ?? 0}
        onPageChange={setPage}
        onPageSizeChange={(size) => { setPageSize(size); setPage(1); }}
        ariaLabel="Users table pagination"
      />
    </section>
  );
}

Server stub (optional for local testing):

// dev-server.ts (Express)
import express from 'express';
const app = express();
const PORT = 3001;

const USERS = Array.from({ length: 1287 }, (_, i) => ({ id: i + 1, name: `User ${i + 1}`, email: `u${i + 1}@mail.com` }));

app.get('/api/users', (req, res) => {
  const page = Math.max(1, Number(req.query.page) || 1);
  const pageSize = Math.max(1, Math.min(200, Number(req.query.pageSize) || 20));
  const start = (page - 1) * pageSize;
  const items = USERS.slice(start, start + pageSize);
  res.json({ items, total: USERS.length, page, pageSize });
});

app.listen(PORT, () => console.log(`API on http://localhost:${PORT}`));

Performance tips

  • Memoize derived values: page slices, column arrays, and render callbacks with useMemo/useCallback.
  • KeepPreviousData: With React Query, keep the old page’s data during refetch for a smooth transition.
  • Virtualize rows: Even with pagination, 100+ rows can be heavy with complex cells. Use react-window or react-virtualized.
  • Avoid re-creating handlers: Pass stable onPageChange callbacks to prevent child re-renders.
  • Batched state updates: When changing pageSize, reset page to 1 in the same tick.

Virtualized example snippet:

// VirtualBody.tsx
import { FixedSizeList as List } from 'react-window';

export function VirtualBody<T>({ rowHeight = 40, data, columns }: { rowHeight?: number; data: T[]; columns: any[]; }) {
  return (
    <List height={400} itemCount={data.length} itemSize={rowHeight} width="100%" itemData={{ data, columns }}>
      {({ index, style, data }) => {
        const row = data.data[index];
        return (
          <tr style={style}>
            {data.columns.map((c: any) => (
              <td key={String(c.key)}>{c.render ? c.render(row) : (row as any)[c.key]}</td>
            ))}
          </tr>
        );
      }}
    </List>
  );
}

Accessibility checklist

  • Navigation semantics: Wrap controls in
  • Current page: Use aria-current=“page” on the active number.
  • Live region: Announce page changes via aria-live=“polite”.
  • Focus states: Ensure visible focus rings on buttons/select.
  • Hit areas: Buttons should be at least 32–40 px tall with clear contrast.
  • Keyboard support: Tab to focus; Enter/Space to activate; consider Left/Right arrow handlers if you manage roving focus.

Testing the pagination logic

Unit test the pure function; this catches off‑by‑one errors and ellipsis edge cases.

// paginationRange.test.ts
import { getPaginationRange, ELLIPSIS } from './paginationRange';

test('shows all pages when few total pages', () => {
  expect(getPaginationRange({ totalItems: 40, pageSize: 10, page: 2 })).toEqual([1, 2, 3, 4]);
});

test('shows ellipses for large page counts', () => {
  const out = getPaginationRange({ totalItems: 1000, pageSize: 10, page: 50, siblingCount: 1 });
  expect(out[0]).toBe(1);
  expect(out.includes(ELLIPSIS)).toBe(true);
});

test('clamps page bounds', () => {
  const out = getPaginationRange({ totalItems: 100, pageSize: 10, page: 999 });
  expect(out[out.length - 1]).toBe(10);
});

Common pitfalls

  • Forgetting total count: Without totalItems, you can’t compute total pages; ask backend to return it alongside items.
  • Resetting page on filters: When filters change, reset to page 1 to avoid out-of-range indices.
  • Inconsistent 0- vs 1-based indexing: Keep UI 1-based; convert to 0-based only for array slicing.
  • Flicker on server-side fetch: Use keepPreviousData to avoid spinners between pages.
  • Ellipses treated as buttons: Ensure ellipses are spans, not interactive.

When to use infinite scroll instead

  • Content discovery feeds where users browse, not seek precision.
  • Mobile-first experiences with short attention spans.
  • Known drawbacks: Hard to reach footer, losing scroll position after navigation, difficult to share a “position.”

If users need to jump to an exact page, compare results, or export ranges, pagination is superior.

Wrap up

You now have a robust Pagination component and a simple, semantic DataTable that work together for both client- and server-side data. Start with accessible building blocks, add performance techniques as your dataset grows, and enforce correctness with unit tests. From here, you can integrate sorting, column pinning, row selection, CSV export, and virtualization for truly large tables—all while keeping the pagination UX consistent and fast.

Related Posts