@akhatmirzo/ui

DataTable

A data table with sorting, Uzbek-aware search, filters, selection, pagination, server mode and virtual scrolling.

Holat
#1042Jasur KarimovToshkent21.10.2026To'langan85 000 so'm
#1043Nodira SaidovaBuxoro20.10.2026Kutilmoqda462 311 so'm
#1044Malika RahimovaXorazm19.10.2026Yo'lda839 622 so'm
#1045Aziz NormatovSamarqand18.10.2026Bekor qilingan1 216 933 so'm
#1046Bekzod G'aniyevFarg'ona17.10.2026Kutilmoqda1 594 244 so'm
1–5 / 48

Import

import { DataTable, type DataTableColumn } from '@akhatmirzo/ui';

DataTable is built on TanStack Table, but you don't need to know its API: give it data and a list of columns, and sorting, search, filters, pagination and the loading, empty and error states are ready to go.

When to use

  • Many records with the same shape — orders, customers, payments, employees. Users compare, sort and search.
  • For a short list (5–10 rows, no sorting) a plain <table> or a few Cards are enough.
  • Lots of data (thousands of rows) — two options: paginate on the server (manual), or, if everything is already in the browser, use virtual.
  • Rows that open something — use onRowClick; for several actions per row use rowActions (usually a DropdownMenu).
  • Mark number columns as numeric — they align right and use equal-width digits (tabular-nums), so amounts are easy to compare at a glance.

Columns

Each column is a DataTableColumn<T> object. The minimum is key and header: the value comes from row[key] and is rendered as text. Everything else is optional:

columns.tsx
import { formatDate, formatMoney } from '@akhatmirzo/core';
import { Badge, type DataTableColumn } from '@akhatmirzo/ui';

export interface Order {
  id: number;
  customer: { name: string; phone: string };
  date: Date;
  status: 'paid' | 'pending';
  total: number;
}

export const columns: DataTableColumn<Order>[] = [
  { key: 'id', header: '№', width: 88, sortable: true, hideable: false },
  {
    key: 'customer',
    header: 'Mijoz',
    accessor: (o) => o.customer.name, // sorting, filters and search use this value
    sortable: true,
  },
  {
    key: 'phone',
    header: 'Telefon',
    accessor: (o) => o.customer.phone,
    hidden: true, // can be turned on from the "Columns" menu
  },
  {
    key: 'date',
    header: 'Sana',
    sortable: true,
    filter: 'date',
    searchable: false,
    cell: (o) => formatDate(o.date, 'dd.MM.yyyy, HH:mm'),
  },
  {
    key: 'status',
    header: 'Holat',
    filter: 'multiselect',
    filterOptions: [
      { value: 'paid', label: "To'langan" },
      { value: 'pending', label: 'Kutilmoqda' },
    ],
    searchValue: (o) => (o.status === 'paid' ? "To'langan" : 'Kutilmoqda'),
    cell: (o) =>
      o.status === 'paid' ? <Badge color="success">To'langan</Badge> : <Badge>Kutilmoqda</Badge>,
  },
  {
    key: 'total',
    header: 'Summa',
    numeric: true,
    sortable: true,
    filter: 'range',
    cell: (o) => formatMoney(o.total),
  },
];
PropTypeDefaultDescription
keyrequiredstring—Column id; without accessor, the value is row[key]
headerrequiredReactNode—Column heading
accessor(row: T) => unknown—Value used for sorting, filters and search (defaults to row[key])
cell(row: T) => ReactNode—How the cell renders (defaults to the value as text; dates as dd.MM.yyyy)
widthnumber | string—Width: px or any CSS value
align'left' | 'center' | 'right'—Alignment (left by default, right for numeric)
numericboolean—Right-aligned, tabular-nums
sortableboolean—Sort by clicking the header
filter'select' | 'multiselect' | 'range' | 'date'—Filter type (in the "Filters" popover)
filterOptionsDataTableFilterOption[]—select / multiselect options { value, label } (defaults to unique values in the data)
filterLabelstring—Name in the filter and its badge (defaults to the header text or key)
searchablebooleantrueInclude the column in the global search
searchValue(row: T) => string—Text to search (e.g. a status name instead of its code)
hideablebooleantrueCan be hidden from the "Columns" menu
hiddenboolean—Hidden initially
classNamestring—Class for the cell (td)

Keep value and display apart

cell is for display only. Sorting, filters and search work on the value (accessor or row[key]). So store amounts as number and dates as Date, and format them in cell — sorting stays correct, and a search finds both "1042" and "1 042".

Sorting

Click a sortable header: ascending → descending → off. Hold Shift to add the column to the current sort (multi-column sorting); the header shows its position. Sorting understands the value type: numbers, dates, booleans and text (Uzbek alphabet order, "10" > "9"); empty values go last.

Set an initial sort with initialState:

<DataTable
  data={orders}
  columns={columns}
  initialState={{ sorting: [{ id: 'date', desc: true }] }}
/>

searchable adds a search field above the table. It's debounced by 300ms and understands how Uzbek is written: Cyrillic and Latin match each other (жасур → "Jasur"), and oʻ, o' and o‘ are treated the same. It searches every column except those with searchable: false; give columns whose display differs from the value a searchValue.

Filters

Add filter to a column and a "Filtrlar" (filters) button appears in the toolbar. Active filters show up as badges above the table and can be removed with ✕. When nothing matches, a "Filtrlarni tozalash" (clear filters) button appears.

TypeControlValue (DataTableFilterValue)
selectRadio: one valuestring
multiselectCheckboxes: several valuesstring[]
rangeTwo numbers: from — to{ min?: number; max?: number }
dateTwo dates: start — end{ from?: Date | null; to?: Date | null }

select and multiselect options come from filterOptions, or from the unique values in the data, each with a row count next to it. A date range includes both ends (the end day too). The demo below sets an initial filter through initialState.

To'lov turi: Click
№MijozTo'lov turiViloyat
#501Dilnoza YusupovaClickToshkent25.10.202650 000 so'm
#505Dilnoza YusupovaClickSamarqand21.10.2026903 828 so'm
#509Dilnoza YusupovaClickAndijon17.10.20261 757 656 so'm
#513Dilnoza YusupovaClickNamangan13.10.20262 611 484 so'm
#517Dilnoza YusupovaClickToshkent09.10.2026465 312 so'm
1–5 / 10

Selection and bulk actions

selectable adds a checkbox column. The header checkbox selects the current page (and shows an indeterminate state for a partial selection). Once a row is selected, the toolbar turns into a bulk actions bar: "2 ta tanlandi" (2 selected), your bulkActions buttons and "Bekor qilish" (clear). Shift-click a checkbox to select a range.

ArtikulAmallar
Paxta ko'ylakKY-00142
Atlas ro'molRM-0148
Choynak (1.5 l)CH-2030
Do'ppiDP-077120
Sopol laganLG-31015
<DataTable
  data={products}
  columns={columns}
  selectable
  getRowId={(p) => p.sku} // defaults to row.id, or the row index
  onSelectionChange={(ids) => setSelectedIds(ids)}
  bulkActions={(rows, clearSelection) => (
    <Button
      size="sm"
      onClick={() => {
        toast(`${rows.length} products archived`);
        clearSelection();
      }}
    >
      Archive
    </Button>
  )}
/>

Selection can be controlled too: selectedIds + onSelectionChange. rowActions renders actions at the end of each row (clicking them doesn't trigger onRowClick).

Pagination

pagination={{ pageSize: 10 }} adds "1–10 / 248", page numbers and a page size picker at the bottom (options 10, 25, 50, 100 by default; change them with pageSizeOptions). Changing the sort, search or filters jumps back to the first page; if a deletion empties the current page, the table moves to the last page that still exists. Without pagination, every row renders at once — for large data sets use virtual (which also shows a row count at the bottom).

States

StateWhat you see
First loadloading + empty data — skeleton rows
Refetchingloading + previous data — 60% opacity and a thin progress bar
No dataempty — icon, title, description, action button
Nothing foundEmpty search/filter result — "Filtrlarni tozalash"
Errorerror (text or true) + onRetry — a "Qayta urinish" (retry) button

While refetching, the table doesn't jump — the old data stays in place. Click through the states:

№MijozSumma

Server mode

When the server sorts, filters and paginates, pass manual. The table leaves the data alone and reports sorting, search, filters and page through onStateChange; you send that state to your API. Pass the total number of records as rowCount for pagination.

The state passed to onStateChange (DataTableState):

interface DataTableState {
  sorting: { id: string; desc: boolean }[]; // id — the column key
  search: string; // value after the 300ms debounce
  filters: Record<string, DataTableFilterValue>; // column key → filter value
  pagination: { pageIndex: number; pageSize: number }; // pageIndex starts at 0
}

In a real app you turn the state into URL parameters and fetch:

OrdersTable.tsx
import { DataTable, type DataTableState } from '@akhatmirzo/ui';
import { useEffect, useState } from 'react';
import { columns, type Order } from './columns';

export function OrdersTable() {
  const [state, setState] = useState<DataTableState>({
    sorting: [],
    search: '',
    filters: {},
    pagination: { pageIndex: 0, pageSize: 25 },
  });
  const [page, setPage] = useState<{ rows: Order[]; total: number }>({ rows: [], total: 0 });
  const [loading, setLoading] = useState(true);
  const [error, setError] = useState<string>();

  useEffect(() => {
    const controller = new AbortController();
    const params = new URLSearchParams({
      page: String(state.pagination.pageIndex + 1),
      size: String(state.pagination.pageSize),
      q: state.search,
      sort: state.sorting.map((s) => (s.desc ? '-' : '') + s.id).join(','),
      filters: JSON.stringify(state.filters),
    });
    setLoading(true);
    fetch(`/api/orders?${params}`, { signal: controller.signal })
      .then((res) => res.json())
      .then((data: { rows: Order[]; total: number }) => {
        setPage(data);
        setError(undefined);
        setLoading(false);
      })
      .catch((err: Error) => {
        if (err.name === 'AbortError') return;
        setError('The server did not respond');
        setLoading(false);
      });
    return () => controller.abort();
  }, [state]);

  return (
    <DataTable
      label="Orders"
      data={page.rows}
      rowCount={page.total}
      columns={columns}
      manual
      searchable
      pagination={{ pageSize: 25 }}
      loading={loading}
      error={error}
      onRetry={() => setState({ ...state })}
      onStateChange={setState}
    />
  );
}

Filter options in server mode

With manual, the table only sees the current page, so always pass filterOptions for select and multiselect filters — otherwise the options are collected from the visible rows only. Row counts next to the options aren't shown in this mode.

Virtual scrolling — 10,000 rows

If all the data is in the browser and you don't want pages, use virtual. Only the visible rows (plus a small buffer, virtual={{ overscan: 10 }}) are in the DOM, while sorting, search and filters still work on the full data set. The height comes from maxHeight (480px by default), and the header is sticky. pagination is ignored together with virtual.

Viloyat
1Jasur KarimovToshkent50 000 so'm
2Malika RahimovaNavoiy427 311 so'm
3Farhod TursunovSamarqand804 622 so'm
4Mohira NazarovaXorazm1 181 933 so'm
5Jasur KarimovBuxoro1 559 244 so'm
6Malika RahimovaToshkent1 936 555 so'm
7Farhod TursunovNavoiy2 313 866 so'm
8Mohira NazarovaSamarqand2 691 177 so'm
9Jasur KarimovXorazm3 068 488 so'm
10Malika RahimovaBuxoro3 445 799 so'm
11Farhod TursunovToshkent3 823 110 so'm
12Mohira NazarovaNavoiy4 200 421 so'm
13Jasur KarimovSamarqand4 577 732 so'm
14Malika RahimovaXorazm155 043 so'm
15Farhod TursunovBuxoro532 354 so'm
16Mohira NazarovaToshkent909 665 so'm
17Jasur KarimovNavoiy1 286 976 so'm
10 000 ta qator

For lists that aren't tables, the same technique is available as VirtualList.

Density, sticky column and column visibility

  • density="compact" — 40px rows (the default comfortable is 52px).
  • stickyFirstColumn — the first column (and the selection checkbox) stays put during horizontal scrolling. On a narrow screen, scroll the demo sideways.
  • The "Ustunlar" (columns) menu (columnToggle, on by default) shows and hides columns; hideable: false columns aren't listed, hidden: true starts them hidden.
  • With maxHeight, the header sticks inside the table's scroll area.
XodimLavozimBo'limTelefonMaosh
Sarvar AbduxoliqovFrontend dasturchiIT+998 90 123 45 6714 500 000 so'm
Gulnora O'rinovaBuxgalterMoliya+998 93 555 12 349 800 000 so'm
Javohir IsmoilovDizaynerMarketing+998 97 700 00 1111 200 000 so'm

Cards on phones

By default, a narrow screen scrolls the table horizontally. mobileView="cards" turns each row into a card (header: value) below 640px. Make your browser window narrower to see it.

№MijozViloyatSumma
1042Jasur KarimovToshkent1 250 000 so'm
1043Malika RahimovaSamarqand480 000 so'm
1044Aziz NormatovFarg'ona2 730 000 so'm

Props

Accepts the remaining <div> attributes (on the root). className goes on the root, tableClassName on the <table>.

Data

PropTypeDefaultDescription
datarequiredreadonly T[]—Rows
columnsrequiredreadonly DataTableColumn<T>[]—Columns (see the table above)
getRowId(row: T, index: number) => string—Row id (for selection). Defaults to row.id, or the row index
labelstring—Table name for screen readers

Features

PropTypeDefaultDescription
searchablebooleanfalseGlobal search (300ms, Uzbek-aware)
selectablebooleanfalseCheckbox column
selectedIdsstring[]—Selected rows (controlled)
defaultSelectedIdsstring[]—
onSelectionChange(ids: string[], rows: T[]) => void—
bulkActions(rows: T[], clearSelection: () => void) => ReactNode—Bulk actions bar
paginationfalse | { pageSize?: number; pageSizeOptions?: number[] }—Pagination; off when omitted
virtualboolean | { overscan?: number }falseVirtual scrolling (instead of pages)
maxHeightnumber | string—Height of the table area; 480 by default with virtual. Makes the header sticky
columnTogglebooleantrueThe "Columns" menu
initialStatePartial<DataTableState>—Initial sorting, search, filters and page
onRowClick(row: T) => void—Called on row click (or Enter)
rowActions(row: T) => ReactNode—Actions at the end of a row
toolbarReactNode—Extra toolbar content (e.g. an "Add" button)

Server mode

PropTypeDefaultDescription
manualbooleanfalseSorting, filters and paging are not applied to data
rowCountnumber—Total records in manual mode
onStateChange(state: DataTableState) => void—Called when the state changes (in any mode)

States and appearance

PropTypeDefaultDescription
loadingbooleanfalseNo data — skeleton; with data — faded + progress bar
errorReactNode—Error text, or true
onRetry() => void—Shows a retry button
emptyDataTableEmptyState—Empty state: { icon, title, description, action } (all ReactNode)
density'comfortable' | 'compact''comfortable'Row height: 52 / 40px
stickyFirstColumnbooleanfalse
mobileView'table' | 'cards''table'Layout on phones (< 640px)
labelsDataTableLabels—All UI strings
tableClassNamestring—Class for the <table>

Strings (labels)

Every string has an Uzbek default; override only the keys you need. Strings that include a number are functions:

<DataTable
  labels={{
    search: 'Search by customer, № or region',
    selected: (n) => `${n} orders selected`,
    range: (from, to, total) => `${from}–${to} of ${total}`,
  }}
/>

Keys: search, searchAria, filters, columns, selectAll, selectRow, selected, clearSelection, emptyTitle, emptyDescription, noResultsTitle, noResultsDescription, clearFilters, errorTitle, retry, range, total, noRows, pageSize, prevPage, nextPage, page, pagination, rowActions, loading, all, min, max, from, to, removeFilter.

Data attributes

AttributeElementValue
data-row-idtrthe row id
data-selectedtrpresent on selected rows
data-sortedsort buttonasc · desc

data-slot: data-table, data-table-toolbar, data-table-bulk, data-table-scroll, data-table-footer, data-table-state.

Keyboard

KeyAction
TabSearch, filters, header buttons, rows, row actions
Enter (on a header)Sort: ascending → descending → off
Enter (on a row)onRowClick
Space (on a row)Toggle selection (selectable)

Rows are focusable only when onRowClick or selectable is set.

Accessibility

  • A real <table> with <th scope="col">; the table is named through label (aria-label).
  • Sorting is exposed on the header with aria-sort (ascending / descending / none); sortable headers are <button>s.
  • Loading sets aria-busy on the table; the refetch bar is a role="progressbar".
  • State messages (empty, nothing found) use role="status"; errors use role="alert".
  • Selection: each checkbox is labelled "Qatorni tanlash: <id>" (select row), the header one "Hammasini tanlash" (select all); "N ta tanlandi" is announced via aria-live.
  • Pagination is a <nav aria-label="Sahifalash">, the current page has aria-current="page", and "1–10 / 248" lives in an aria-live region.
  • Filters: every group is labelled, and each badge's ✕ button has a name like "Holat filtrini olib tashlash" (remove the status filter).
  • Virtual mode sets aria-rowcount and aria-rowindex on each row, so screen readers know the total number of rows.

On this page