Skip to main content

Data Table Implementation

Patterns for building interactive data tables with sorting, filtering, selection, and virtual scrolling.

Problem

You need to display tabular data with:
  • Column-based sorting
  • Client-side filtering
  • Row selection (single/multi)
  • Virtual scrolling for large datasets
  • Custom cell rendering

Solution

Use ui.table() widget with state-managed sorting, filtering, and selection.

Basic Table with Sorting & Selection

import { ui } from "@rezi-ui/core";
import { createNodeApp } from "@rezi-ui/node";

type User = { id: string; name: string; email: string; role: string };
type SortDirection = "asc" | "desc";

type State = {
  users: User[];
  sort: { column: string; direction: SortDirection };
  filter: string;
  selection: string[];
};

function filterUsers(users: User[], filter: string): User[] {
  if (!filter) return users;
  const lower = filter.toLowerCase();
  return users.filter(
    (u) =>
      u.name.toLowerCase().includes(lower) ||
      u.email.toLowerCase().includes(lower)
  );
}

function sortUsers(
  users: User[],
  column: string,
  direction: SortDirection
): User[] {
  const sorted = [...users].sort((a, b) => {
    const aVal = a[column as keyof User];
    const bVal = b[column as keyof User];
    if (aVal < bVal) return direction === "asc" ? -1 : 1;
    if (aVal > bVal) return direction === "asc" ? 1 : -1;
    return 0;
  });
  return sorted;
}

const app = createNodeApp<State>({
  initialState: {
    users: [
      { id: "1", name: "Ada Lovelace", email: "[email protected]", role: "Admin" },
      { id: "2", name: "Linus Torvalds", email: "[email protected]", role: "User" },
      { id: "3", name: "Grace Hopper", email: "[email protected]", role: "User" },
      { id: "4", name: "Alan Turing", email: "[email protected]", role: "Admin" },
    ],
    sort: { column: "name", direction: "asc" },
    filter: "",
    selection: [],
  },
});

app.view((state) => {
  const filtered = filterUsers(state.users, state.filter);
  const sorted = sortUsers(filtered, state.sort.column, state.sort.direction);

  return ui.page({ p: 1 }, [
    ui.panel("Users", [
      ui.row({ gap: 1, pb: 1 }, [
        ui.text("Search:", { variant: "label" }),
        ui.input({
          id: "filter",
          value: state.filter,
          placeholder: "Filter by name or email...",
          onInput: (value) => app.update((s) => ({ ...s, filter: value })),
        }),
      ]),

      ui.table({
        id: "users",
        data: sorted,
        getRowKey: (u) => u.id,
        columns: [
          { key: "name", header: "Name", flex: 1, sortable: true },
          { key: "email", header: "Email", flex: 2, sortable: true },
          { key: "role", header: "Role", width: 10, sortable: true },
        ],
        selectionMode: "multi",
        selection: state.selection,
        onSelectionChange: (keys) =>
          app.update((s) => ({ ...s, selection: [...keys] })),
        sortColumn: state.sort.column,
        sortDirection: state.sort.direction,
        onSort: (column, direction) =>
          app.update((s) => ({ ...s, sort: { column, direction } })),
        stripedRows: true,
      }),

      state.selection.length > 0 &&
        ui.row({ gap: 1, pt: 1 }, [
          ui.text(`${state.selection.length} selected`, { variant: "caption" }),
          ui.button({
            id: "clear-selection",
            label: "Clear",
            intent: "secondary",
            onPress: () => app.update((s) => ({ ...s, selection: [] })),
          }),
        ]),
    ]),
  ]);
});

app.keys({
  "ctrl+c": () => app.stop(),
  q: () => app.stop(),
});

await app.start();

Column Configuration

Fixed and Flex Widths

ui.table({
  id: "table",
  data: rows,
  getRowKey: (row) => row.id,
  columns: [
    { key: "id", header: "ID", width: 8 }, // Fixed 8 cells
    { key: "name", header: "Name", flex: 2 }, // 2x flex weight
    { key: "email", header: "Email", flex: 3 }, // 3x flex weight
    { key: "status", header: "Status", width: 12 }, // Fixed 12 cells
  ],
});

Column Constraints

columns: [
  {
    key: "description",
    header: "Description",
    flex: 1,
    minWidth: 20, // Minimum 20 cells
    maxWidth: 60, // Maximum 60 cells
  },
];

Text Overflow Handling

columns: [
  { key: "email", header: "Email", flex: 1, overflow: "ellipsis" }, // "user@ex..."
  { key: "name", header: "Name", flex: 1, overflow: "middle" }, // "Ada...ace"
  { key: "id", header: "ID", width: 8, overflow: "clip" }, // Hard truncate
];

Cell Alignment

columns: [
  { key: "name", header: "Name", flex: 1, align: "left" }, // Default
  { key: "score", header: "Score", width: 8, align: "right" }, // Numeric
  { key: "status", header: "Status", width: 12, align: "center" }, // Centered
];

Custom Cell Rendering

Render Function

type User = { id: string; name: string; status: "active" | "inactive"; score: number };

ui.table<User>({
  id: "users",
  data: users,
  getRowKey: (u) => u.id,
  columns: [
    { key: "name", header: "Name", flex: 1 },
    {
      key: "status",
      header: "Status",
      width: 12,
      render: (value) =>
        ui.badge(String(value), {
          variant: value === "active" ? "success" : "error",
        }),
    },
    {
      key: "score",
      header: "Score",
      width: 10,
      align: "right",
      render: (value) =>
        ui.text(String(value), {
          style: { bold: Number(value) >= 90 },
        }),
    },
  ],
});

Row-Based Rendering

columns: [
  {
    key: "actions",
    header: "Actions",
    width: 20,
    render: (_value, row) =>
      ui.row({ gap: 1 }, [
        ui.button({
          id: `edit-${row.id}`,
          label: "Edit",
          intent: "secondary",
          onPress: () => handleEdit(row),
        }),
        ui.button({
          id: `delete-${row.id}`,
          label: "Delete",
          intent: "danger",
          onPress: () => handleDelete(row),
        }),
      ]),
  },
];

Selection Modes

Single Selection

ui.table({
  id: "table",
  data: rows,
  getRowKey: (row) => row.id,
  columns: [...],
  selectionMode: "single",
  selection: state.selectedId ? [state.selectedId] : [],
  onSelectionChange: (keys) =>
    app.update((s) => ({ ...s, selectedId: keys[0] ?? null })),
});

Multi Selection with Keyboard Modifiers

ui.table({
  id: "table",
  data: rows,
  getRowKey: (row) => row.id,
  columns: [...],
  selectionMode: "multi",
  selection: state.selection,
  onSelectionChange: (keys) =>
    app.update((s) => ({ ...s, selection: [...keys] })),
});
Multi-select interactions:
  • Click - Select only clicked row
  • Ctrl+Click - Toggle row selection
  • Shift+Click - Select range from last clicked to current

No Selection

ui.table({
  id: "table",
  data: rows,
  getRowKey: (row) => row.id,
  columns: [...],
  selectionMode: "none", // Disable selection
});

Sorting

Declarative Sorting

type State = {
  data: User[];
  sortColumn: string;
  sortDirection: "asc" | "desc";
};

app.view((state) => {
  const sorted = sortData(state.data, state.sortColumn, state.sortDirection);

  return ui.table({
    id: "table",
    data: sorted,
    getRowKey: (row) => row.id,
    columns: [
      { key: "name", header: "Name", flex: 1, sortable: true },
      { key: "email", header: "Email", flex: 2, sortable: true },
      { key: "role", header: "Role", width: 10, sortable: false }, // Not sortable
    ],
    sortColumn: state.sortColumn,
    sortDirection: state.sortDirection,
    onSort: (column, direction) =>
      app.update((s) => ({ ...s, sortColumn: column, sortDirection: direction })),
  });
});

Sort Cycle

Clicking a sortable header cycles through:
  1. Unsorted → Ascending
  2. Ascending → Descending
  3. Descending → Ascending (cycles back)

useTable Hook: Integrated State Management

For simpler table state wiring, use the useTable hook:
import { defineWidget, ui, useTable } from "@rezi-ui/core";

type User = { id: string; name: string; email: string; role: string };

const UserTable = defineWidget<{ users: User[] }>((ctx) => {
  const table = useTable<User>(ctx, {
    id: "users",
    rows: ctx.props.users,
    columns: [
      { key: "name", header: "Name", flex: 1 },
      { key: "email", header: "Email", flex: 2 },
      { key: "role", header: "Role", width: 10 },
    ],
    selectable: "multi", // "none" | "single" | "multi"
    sortable: true, // Auto-enable sorting on all columns
    defaultSortColumn: "name",
    defaultSortDirection: "asc",
  });

  return ui.column({ gap: 1 }, [
    ui.table(table.props),
    table.selection.length > 0 &&
      ui.row({ gap: 1 }, [
        ui.text(`${table.selection.length} selected`, { variant: "caption" }),
        ui.button({
          id: "clear",
          label: "Clear",
          onPress: table.clearSelection,
        }),
      ]),
  ]);
});
Benefits:
  • Auto-manages selection state
  • Auto-manages sort state
  • Auto-sorts data
  • Provides helper methods (clearSelection, setSort)

Virtual Scrolling for Large Datasets

For tables with 1000+ rows, use ui.virtualList() with custom row rendering:
import { ui } from "@rezi-ui/core";

type LogEntry = { id: string; timestamp: string; level: string; message: string };

function renderRow(entry: LogEntry, index: number) {
  return ui.row({ gap: 2, key: entry.id }, [
    ui.text(entry.timestamp, { width: 20 }),
    ui.badge(entry.level, {
      variant: entry.level === "error" ? "error" : "info",
      width: 8,
    }),
    ui.text(entry.message, { flex: 1 }),
  ]);
}

app.view((state) => {
  return ui.page({ p: 1 }, [
    ui.panel("Logs", [
      // Header row
      ui.row({ gap: 2, style: { bold: true } }, [
        ui.text("Timestamp", { width: 20 }),
        ui.text("Level", { width: 8 }),
        ui.text("Message", { flex: 1 }),
      ]),
      ui.divider(),
      // Virtual list for rows
      ui.virtualList({
        id: "logs",
        items: state.logs, // Array of 10,000+ items
        renderItem: renderRow,
        getItemKey: (entry) => entry.id,
        height: 20, // Viewport height in rows
        itemHeight: 1, // Each row is 1 cell tall
      }),
    ]),
  ]);
});
Virtual list benefits:
  • Renders only visible rows
  • Handles 100k+ items efficiently
  • Smooth scrolling via keyboard/mouse
  • Automatic scroll position management

Filtering Patterns

Client-Side Text Filter

function filterData<T extends Record<string, unknown>>(
  data: T[],
  filter: string,
  searchFields: (keyof T)[]
): T[] {
  if (!filter) return data;
  const lower = filter.toLowerCase();
  return data.filter((item) =>
    searchFields.some((field) =>
      String(item[field]).toLowerCase().includes(lower)
    )
  );
}

const filtered = filterData(state.users, state.filter, ["name", "email"]);

Multi-Column Filters

type Filters = {
  name: string;
  role: string;
  minScore: number;
};

function applyFilters(users: User[], filters: Filters): User[] {
  return users.filter((u) => {
    if (filters.name && !u.name.toLowerCase().includes(filters.name.toLowerCase())) {
      return false;
    }
    if (filters.role && u.role !== filters.role) {
      return false;
    }
    if (filters.minScore && u.score < filters.minScore) {
      return false;
    }
    return true;
  });
}

app.view((state) => {
  const filtered = applyFilters(state.users, state.filters);

  return ui.panel("Users", [
    ui.row({ gap: 1 }, [
      ui.input({
        id: "filter-name",
        value: state.filters.name,
        placeholder: "Filter by name...",
        onInput: (value) =>
          app.update((s) => ({
            ...s,
            filters: { ...s.filters, name: value },
          })),
      }),
      ui.select({
        id: "filter-role",
        value: state.filters.role,
        placeholder: "Filter by role...",
        options: [
          { value: "", label: "All Roles" },
          { value: "Admin", label: "Admin" },
          { value: "User", label: "User" },
        ],
        onChange: (value) =>
          app.update((s) => ({
            ...s,
            filters: { ...s.filters, role: value },
          })),
      }),
    ]),
    ui.table({ id: "users", data: filtered, columns: [...], getRowKey: (u) => u.id }),
  ]);
});

Pagination

type State = {
  data: User[];
  page: number;
  pageSize: number;
};

function paginateData<T>(data: T[], page: number, pageSize: number): T[] {
  const start = page * pageSize;
  return data.slice(start, start + pageSize);
}

app.view((state) => {
  const totalPages = Math.ceil(state.data.length / state.pageSize);
  const paginated = paginateData(state.data, state.page, state.pageSize);

  return ui.panel("Users", [
    ui.table({
      id: "users",
      data: paginated,
      columns: [...],
      getRowKey: (u) => u.id,
    }),
    ui.row({ gap: 2, justify: "between", pt: 1 }, [
      ui.text(
        `${state.page * state.pageSize + 1}-${Math.min((state.page + 1) * state.pageSize, state.data.length)} of ${state.data.length}`,
        { variant: "caption" }
      ),
      ui.pagination({
        id: "pagination",
        page: state.page,
        totalPages,
        onChange: (page) => app.update((s) => ({ ...s, page })),
      }),
    ]),
  ]);
});

Styling

Striped Rows

import { rgb } from "@rezi-ui/core";

ui.table({
  id: "table",
  data: rows,
  columns: [...],
  getRowKey: (row) => row.id,
  stripeStyle: {
    even: rgb(15, 20, 25),
    odd: rgb(20, 26, 34),
  },
});

Border Style

ui.table({
  id: "table",
  data: rows,
  columns: [...],
  getRowKey: (row) => row.id,
  borderStyle: {
    variant: "rounded", // "single" | "double" | "rounded" | "heavy"
  },
});

Best Practices

  1. Always provide getRowKey - Stable keys prevent state loss during updates
  2. Filter before sort - More efficient pipeline
  3. Use virtual scrolling for 1000+ rows - Prevents render bottlenecks
  4. Debounce filter inputs - Reduce re-renders during typing
  5. Show loading state - Use ui.skeleton() or ui.spinner() during data fetch
  6. Handle empty state - Use ui.empty() when no data or no results
  7. Memoize sorted/filtered data - Avoid recomputing on every render
  8. Use useTable for common patterns - Reduces state management boilerplate
  9. Provide visual feedback for selection - Use stripedRows or highlight selected
  10. Keep column config readable - Extract to constants for large tables

Build docs developers (and LLMs) love