Building Headless, Reusable React Table Components for Real-World Dashboards

javascript
#frontend-architecture#headless-components#performance#react#reusable-components#table
šŸ‘¤admin
šŸ“…Last updated 13 days ago

In a 4‑year-old frontend, data tables tend to accumulate conditional props and flags until they are impossible to reason about. A more maintainable pattern is a headless table component: business logic lives in a hook, while rendering stays completely customizable. The hook owns concerns like sorting, pagination, column visibility, and selection, and exposes a minimal API surface instead of dozens of Boolean props.

This separation improves performance and testability. You can unit test the hook with plain data and stubbed events, while the table UI remains a thin presentational layer. For financial or e‑commerce dashboards, it also lets you reuse the same behavior with different designs: dense layouts for ops teams, card-like layouts for customers, or virtualized views for huge datasets. The key is to make the hook opinionated about state transitions but unopinionated about markup so it scales with new requirements instead of fighting them.


 

šŸ’»Source Code

// headless-table/useHeadlessTable.ts
import { useMemo, useState } from "react";

export type Row = Record<string, unknown>;

export type Column<T extends Row> = {
  key: keyof T;
  label: string;
  sortable?: boolean;
  width?: number | string;
};

export type SortState<T extends Row> = {
  key: keyof T | null;
  direction: "asc" | "desc";
};

type Options<T extends Row> = {
  rows: T[];
  columns: Column<T>[];
  initialSort?: SortState<T>;
  pageSize?: number;
};

export function useHeadlessTable<T extends Row>({
  rows,
  columns,
  initialSort,
  pageSize = 20,
}: Options<T>) {
  const [sort, setSort] = useState<SortState<T>>(
    initialSort ?? { key: null, direction: "asc" }
  );
  const [page, setPage] = useState(1);

  const totalPages = Math.max(1, Math.ceil(rows.length / pageSize));

  function toggleSort(key: keyof T) {
    setSort((prev) => {
      if (prev.key !== key) {
        return { key, direction: "asc" };
      }
      return {
        key,
        direction: prev.direction === "asc" ? "desc" : "asc",
      };
    });
    setPage(1);
  }

  const sortedRows = useMemo(() => {
    if (!sort.key) return rows;
    const sorted = [...rows].sort((a, b) => {
      const av = a[sort.key!];
      const bv = b[sort.key!];
      if (av == null && bv == null) return 0;
      if (av == null) return -1;
      if (bv == null) return 1;
      if (av === bv) return 0;
      if (typeof av === "number" && typeof bv === "number") {
        return av - bv;
      }
      return String(av).localeCompare(String(bv));
    });
    if (sort.direction === "desc") sorted.reverse();
    return sorted;
  }, [rows, sort]);

  const paginatedRows = useMemo(() => {
    const start = (page - 1) * pageSize;
    return sortedRows.slice(start, start + pageSize);
  }, [sortedRows, page, pageSize]);

  function goToPage(next: number) {
    setPage(Math.min(totalPages, Math.max(1, next)));
  }

  return {
    columns,
    sort,
    page,
    totalPages,
    rows: paginatedRows,
    allRows: sortedRows,
    toggleSort,
    goToPage,
  };
}

// components/TransactionsTable.tsx – presentational layer
import React from "react";
import { useHeadlessTable, Column } from "./headless-table/useHeadlessTable";

type Transaction = {
  id: string;
  createdAt: string;
  customer: string;
  amount: number;
  status: "pending" | "completed" | "failed";
};

const columns: Column<Transaction>[] = [
  { key: "createdAt", label: "Date", sortable: true, width: 140 },
  { key: "customer", label: "Customer", sortable: true },
  { key: "amount", label: "Amount", sortable: true, width: 120 },
  { key: "status", label: "Status", sortable: true, width: 120 },
];

export function TransactionsTable({ data }: { data: Transaction[] }) {
  const table = useHeadlessTable<Transaction>({
    rows: data,
    columns,
    initialSort: { key: "createdAt", direction: "desc" },
    pageSize: 25,
  });

  return (
    <div className="card">
      <div className="card-header">
        <h2>Transactions</h2>
        <p className="subtitle">
          {data.length.toLocaleString()} total transactions
        </p>
      </div>

      <div className="table-wrapper">
        <table className="data-table">
          <thead>
            <tr>
              {table.columns.map((col) => {
                const isSorted = table.sort.key === col.key;
                const direction = table.sort.direction;
                return (
                  <th
                    key={String(col.key)}
                    style={{ width: col.width }}
                    onClick={() =>
                      col.sortable && table.toggleSort(col.key)
                    }
                    className={col.sortable ? "sortable" : undefined}
                  >
                    <span>{col.label}</span>
                    {col.sortable && (
                      <span className="sort-indicator">
                        {isSorted
                          ? direction === "asc"
                            ? "ā–²"
                            : "ā–¼"
                          : "ā–µ"}
                      </span>
                    )}
                  </th>
                );
              })}
            </tr>
          </thead>
          <tbody>
            {table.rows.length === 0 ? (
              <tr>
                <td colSpan={columns.length} className="empty">
                  No transactions found
                </td>
              </tr>
            ) : (
              table.rows.map((row) => (
                <tr key={row.id}>
                  <td>{new Date(row.createdAt).toLocaleString()}</td>
                  <td>{row.customer}</td>
                  <td>${row.amount.toFixed(2)}</td>
                  <td>
                    <StatusPill status={row.status} />
                  </td>
                </tr>
              ))
            )}
          </tbody>
        </table>
      </div>

      <footer className="table-footer">
        <span>
          Page {table.page} of {table.totalPages}
        </span>
        <div className="pager">
          <button
            onClick={() => table.goToPage(table.page - 1)}
            disabled={table.page === 1}
          >
            Previous
          </button>
          <button
            onClick={() => table.goToPage(table.page + 1)}
            disabled={table.page === table.totalPages}
          >
            Next
          </button>
        </div>
      </footer>
    </div>
  );
}

function StatusPill({ status }: { status: Transaction["status"] }) {
  const color =
    status === "completed"
      ? "success"
      : status === "pending"
      ? "warning"
      : "danger";
  return <span className={`status-pill ${color}`}>{status}</span>;
}

// Example usage (e.g. in a dashboard page)
// <TransactionsTable data={transactions}>

// Minimal CSS sketch (adapt as needed)
/*
.card {
  border-radius: 12px;
  border: 1px solid #e5e7eb;
  padding: 1rem 1.25rem;
  background: #ffffff;
}
.card-header {
  display: flex;
  flex-direction: column;
  gap: 0.25rem;
  margin-bottom: 0.75rem;
}
.table-wrapper {
  overflow-x: auto;
}
.data-table {
  width: 100%;
  border-collapse: collapse;
  font-size: 0.875rem;
}
.data-table th,
.data-table td {
  padding: 0.5rem 0.75rem;
  text-align: left;
  border-bottom: 1px solid #f3f4f6;
}
.data-table th.sortable {
  cursor: pointer;
  user-select: none;
}
.data-table th .sort-indicator {
  margin-left: 0.25rem;
  font-size: 0.7rem;
  opacity: 0.6;
}
.data-table tbody tr:hover {
  background-color: #f9fafb;
}
.empty {
  text-align: center;
  color: #9ca3af;
}
.table-footer {
  margin-top: 0.75rem;
  display: flex;
  justify-content: space-between;
  align-items: center;
}
.pager button {
  margin-left: 0.5rem;
}
.status-pill {
  padding: 0.15rem 0.5rem;
  border-radius: 999px;
  font-size: 0.75rem;
  text-transform: capitalize;
}
.status-pill.success {
  background: #ecfdf3;
  color: #166534;
}
.status-pill.warning {
  background: #fffbeb;
  color: #92400e;
}
.status-pill.danger {
  background: #fef2f2;
  color: #b91c1c;
}
@media (max-width: 640px) {
  .card {
    padding: 0.75rem;
  }
  .data-table th,
  .data-table td {
    padding: 0.4rem 0.5rem;
  }
}
*/

šŸ’¬Comments (0)

šŸ”’Please login to post comments

šŸ’¬

No comments yet. Be the first to share your thoughts!

⚔Actions

Share this snippet:

šŸ‘¤About the Author

a

admin

Active contributor