Skip to main content

Documentation Index

Fetch the complete documentation index at: https://mintlify.com/RtlZeroMemory/Rezi/llms.txt

Use this file to discover all available pages before exploring further.

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: "ada@example.com", role: "Admin" },
      { id: "2", name: "Linus Torvalds", email: "linus@example.com", role: "User" },
      { id: "3", name: "Grace Hopper", email: "grace@example.com", role: "User" },
      { id: "4", name: "Alan Turing", email: "alan@example.com", 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