Pagination
TanStack 표준 페이지네이션 — 클라이언트 슬라이스, 서버 manual 모드, 절대 aria-rowindex 좌표.
개요
페이지네이션은 TanStack Table이 소유합니다. 그리드는 (1) 현재 페이지 행만 렌더하고,
(2) 각 행의 aria-rowindex에 페이지 시작 절대좌표를 가산해 AT가 페이지를 넘어가도 행 번호를
정확히 읽을 수 있게 합니다.
페이지 이동 버튼이나 페이지당 개수 선택 같은 UI는 그리드가 소유하지 않습니다.
소비자가 DS 프리미티브(Pagination / Select)로 직접 합성합니다.
두 가지 모드가 있습니다.
| 모드 | 설정 | 그리드 동작 |
|---|---|---|
| 클라이언트 | getPaginationRowModel() 옵션 | TanStack이 pageIndex × pageSize로 slice |
| 서버(manual) | manualPagination: true + rowCount | 소비자가 현재 페이지 행만 fetch해 주입 — 그리드는 slice하지 않음 |
어느 모드든 aria-rowindex 절대좌표 공식은 동일합니다.
기본 사용법
클라이언트 페이지네이션은 getPaginationRowModel()을 useDataGrid 옵션에 넘기고,
state.pagination + onPaginationChange로 상태를 컨트롤하는 것이 전부입니다.
const data = [ { id: '1', name: '김민준', followers: 128000 }, { id: '2', name: '이서연', followers: 84500 }, { id: '3', name: '박도윤', followers: 233000 }, { id: '4', name: '최하은', followers: 45200 }, { id: '5', name: '정시우', followers: 67800 }, { id: '6', name: '강지우', followers: 152000 }, { id: '7', name: '윤서준', followers: 39100 }, { id: '8', name: '임하준', followers: 98700 }, { id: '9', name: '한지호', followers: 211000 }, { id: '10', name: '오은우', followers: 56300 }, { id: '11', name: '신예은', followers: 73400 }, { id: '12', name: '권도현', followers: 187600 }, ]; const columns = [ { accessorKey: 'name', header: ({ header }) => <DataGrid.HeaderCell header={header}>채널</DataGrid.HeaderCell>, cell: (info) => <DataGrid.Cell cell={info.cell}>{info.getValue()}</DataGrid.Cell>, }, { accessorKey: 'followers', header: ({ header }) => <DataGrid.HeaderCell header={header}>팔로워</DataGrid.HeaderCell>, cell: (info) => <DataGrid.Cell cell={info.cell}>{info.getValue().toLocaleString()}</DataGrid.Cell>, }, ]; function PaginatedGrid() { const [pagination, setPagination] = useState({ pageIndex: 0, pageSize: 5 }); const table = useDataGrid({ data, columns, getCoreRowModel: getCoreRowModel(), getPaginationRowModel: getPaginationRowModel(), getRowId: (row) => row.id, state: { pagination }, onPaginationChange: setPagination, }); const pageCount = table.getPageCount(); return ( <div style={{ display: 'flex', flexDirection: 'column', gap: 16 }}> <DataGrid.Root table={table}> <DataGrid.Header /> <DataGrid.Body /> </DataGrid.Root> <Center> <Pagination.Root total={pageCount} page={pagination.pageIndex + 1} onPageChange={(page) => setPagination((p) => ({ ...p, pageIndex: page - 1 }))} > <Pagination.PrevTrigger /> <Pagination.Items /> <Pagination.NextTrigger /> </Pagination.Root> </Center> </div> ); } render(<PaginatedGrid />);
state.pagination의 타입은 { pageIndex: number; pageSize: number }입니다.
pageIndex는 0-based이고, DS Pagination.Root의 page/onPageChange는 1-based라
변환(pageIndex + 1 ↔ page - 1)이 필요합니다.
table.getPageCount()는 Math.ceil(전체 행 수 / pageSize)를 반환합니다.
이 값을 Pagination.Root의 total에 그대로 넘기면 됩니다.
페이지 크기
pageSize를 바꿀 때는 반드시 pageIndex를 0으로 리셋합니다.
현재 페이지가 새 pageSize 기준 범위를 벗어나는 상황을 막기 위함이며,
이 정책은 그리드가 아니라 소비자 몫입니다.
import { Select } from '@featuring-corp/components';
// PAGE_SIZE_OPTIONS 예: [{ value: '5', label: '5개' }, { value: '10', label: '10개' }, ...]
<Select.Root
size="sm"
value={String(pagination.pageSize)}
onValueChange={(v) => setPagination({ pageIndex: 0, pageSize: Number(v) })}
items={PAGE_SIZE_OPTIONS}
>
<Select.Trigger>
<Select.Value />
</Select.Trigger>
<Select.Portal>
<Select.Positioner>
<Select.Popup>
{PAGE_SIZE_OPTIONS.map((opt) => (
<Select.Item key={opt.value} value={opt.value}>
<Select.ItemText>{opt.label}</Select.ItemText>
</Select.Item>
))}
</Select.Popup>
</Select.Positioner>
</Select.Portal>
</Select.Root>페이지당 개수 선택과 페이지 컨트롤을 함께 쓰는 전체 레시피는 Storybook PageSizeControl 스토리에서 확인하세요.
서버 페이지네이션 (manual)
서버에서 현재 페이지 행만 내려주는 경우에는 manualPagination: true와 rowCount를 함께 지정합니다.
그리드는 slice하지 않고, 소비자가 넘긴 data를 그대로 렌더합니다.
import { useMemo, useState } from 'react';
function ServerPaginatedGrid({ columns }) {
const [pagination, setPagination] = useState({ pageIndex: 0, pageSize: 5 });
// 소비자가 현재 페이지 행만 fetch (예: SWR / React Query)
const { data, isLoading, error } = useFetchPage({
pageIndex: pagination.pageIndex,
pageSize: pagination.pageSize,
});
const items = useMemo(() => data?.items ?? [], [data]);
const table = useDataGrid({
data: items,
columns,
getCoreRowModel: getCoreRowModel(),
getRowId: (row) => row.id,
manualPagination: true,
rowCount: data?.total, // 전체 행 수 — aria-rowcount 및 getPageCount()의 진실
manualSorting: true,
manualFiltering: true,
state: { pagination },
onPaginationChange: setPagination,
loading: isLoading,
error,
skeletonRows: pagination.pageSize, // fetch 동안 자리 유지 (layout shifting 방지)
});
// server 모드에서 페이지 수는 rowCount 기준으로 직접 계산
const pageCount = Math.max(1, Math.ceil((data?.total ?? 0) / pagination.pageSize));
return (
<div style={{ display: 'flex', flexDirection: 'column', gap: 16 }}>
<DataGrid.Root table={table}>
<DataGrid.Header />
<DataGrid.Body />
</DataGrid.Root>
<Center>
<Pagination.Root
total={pageCount}
page={pagination.pageIndex + 1}
onPageChange={(page) => setPagination((p) => ({ ...p, pageIndex: page - 1 }))}
>
<Pagination.PrevTrigger />
<Pagination.Items />
<Pagination.NextTrigger />
</Pagination.Root>
</Center>
</div>
);
}서버 모드에서 rowCount를 지정하지 않으면 전체 행 수를 알 수 없으므로, 그리드는 aria-rowcount를 아예 생략합니다(잘못된 값을 내보내지 않고 degrade). AT가 "전체 N행 중 X행"을 안내하지 못하니 서버 모드에서는 rowCount를 넘기세요.
skeletonRows를 pagination.pageSize로 맞추면 페이지 이동 중 fetch가 일어나는 동안
그리드 높이가 유지됩니다. 스켈레톤 행은 aria-hidden이라 AT에 노출되지 않습니다.
Prop
Type
aria-rowindex — 절대 좌표
페이지네이션이 걸리면 DOM에는 현재 페이지 행만 존재합니다. AT가 "전체 N행 중 X번째 행"을 정확히 안내하려면, 각 행이 전체 기준 절대 위치를 선언해야 합니다.
그리드는 Row에서 다음 공식으로 aria-rowindex를 계산합니다.
// Row.tsx (요지)
const isPaginated =
table.options.manualPagination === true || table.options.getPaginationRowModel != null;
const pageOffset = isPaginated && pagination ? pagination.pageIndex * pagination.pageSize : 0;
const ariaRowIndex = rowIndex !== undefined ? headerRowCount + pageOffset + rowIndex + 1 : undefined;headerRowCount— 헤더 그룹 수(table.getHeaderGroups().length). 헤더 행도aria-rowindex공간을 점유합니다(1..H).pageOffset—pageIndex × pageSize. 2페이지 이후의 행이 1부터 재시작하지 않도록 절대 좌표를 가산합니다.rowIndex—Body가 넘기는 페이지 내부 0-based 위치(가상화=virtualItem.index, 일반=map 인덱스).
결과적으로 pageSize=10 기준 2페이지의 첫 번째 데이터 행은 aria-rowindex = 1(헤더) + 10(offset) + 0 + 1 = 12를 가집니다.
aria-rowcount는 전체 행 수 + 헤더 수로 페이지와 무관하게 고정되어, aria-rowindex ≤ aria-rowcount 단조 불변이 유지됩니다.
이 동작은 클라이언트 모드와 서버 모드 모두 동일합니다. isPaginated 판별이 두 경로를 같은 공식으로 처리합니다.
render-prop으로
Row를 직접 그릴 때: 비가상화면rowIndex를 생략해도 브라우저가 DOM 순서로 산출하므로 괜찮습니다. 가상화 + render-prop이면 화면 밖 행이 DOM에 없으니 소비자가rowIndex를 명시해야 위치 안내가 유지됩니다. 자세한 내용은 Accessibility를 참고하세요.
Pagination 컴포넌트 연동
DS Pagination은 total(페이지 수)과 page(현재 1-based 페이지)를 받아 이전/번호/다음 트리거를 합성합니다.
PrevTrigger / NextTrigger는 경계(1페이지 / 마지막 페이지)에서 자동으로 비활성됩니다.
<Pagination.Root
total={pageCount}
page={pagination.pageIndex + 1}
onPageChange={(page) => setPagination((p) => ({ ...p, pageIndex: page - 1 }))}
>
<Pagination.PrevTrigger />
<Pagination.Items />
<Pagination.NextTrigger />
</Pagination.Root>| prop | 타입 | 설명 |
|---|---|---|
total | number | 전체 페이지 수. 클라이언트: table.getPageCount(), 서버: Math.ceil(rowCount / pageSize) |
page | number | 현재 페이지(1-based). pagination.pageIndex + 1 |
onPageChange | (page: number) => void | 페이지 이동 시 호출. pageIndex로 변환해 setPagination에 전달 |
페이지당 개수 선택(Select)과 총계 표시를 포함한 전체 툴바 레시피는 Storybook에서 확인하세요.
다음 단계
- Sorting — 정렬과 페이지네이션의 조합 (
manualSorting+ 서버 fetch). - Filtering — 필터와 페이지네이션의 조합 (
manualFiltering+pageIndex리셋). - Server-side — 정렬·필터·페이지네이션을 서버에 위임하는 전체 레시피.
- Accessibility —
aria-rowindex절대 좌표의 전체 설계와render-prop가상화 주의사항. - Storybook — Pagination 스토리 — 클라이언트·서버·페이지 크기 선택 살아있는 예제.