Server-Side Data
정렬·필터·페이지네이션을 서버에 위임하고, 로딩·에러를 인스턴스에 연결하며, 비동기 편집으로 CRUD를 완성합니다.
개요
그리드는 현재 상태(정렬 키·필터 값·페이지 번호)를 알릴 뿐, 행을 직접 정렬하거나 자르지 않습니다. 소비자가 그 상태를 fetch 파라미터로 변환해 서버에 넘기고, 서버가 돌려준 행과 total을 그리드에 다시 주입합니다. 그리드는 주입된 행을 그대로 렌더링합니다.
이 방식의 핵심 속성 세 가지입니다.
- 절대
aria-rowindex—rowCount로 전체 행 수를 알려주면, 페이지를 넘겨도 보조기술이 "5000행 중 41번째"를 정확히 읽습니다. - 로딩·에러는 인스턴스에 속한다 —
loading/error/skeletonRows는<DataGrid.Root>prop이 아니라useDataGrid옵션입니다. Root는 받지 않습니다. - 서버 네트워크 레이어는 소비자 코드다 — 그리드 패키지는 fetch를 소유하지 않습니다.
react-query,swr, 직접 작성한useEffect중 무엇이든 사용할 수 있습니다.
서버 정렬·필터·페이지네이션 (manual 모드)
manualSorting / manualFiltering / manualPagination 세 플래그를 useDataGrid에 함께 전달하면
TanStack은 클라이언트 row model(getSortedRowModel 등)을 비활성화하고, 상태 변경을 콜백으로만 알립니다.
소비자는 그 콜백을 자신의 state setter에 연결하고, state가 바뀔 때 서버에 재요청합니다.
'use no memo'; // React Compiler와 TanStack Table mutable 인스턴스 충돌 방지
import { useState, useEffect, useCallback } from 'react';
import { getCoreRowModel, useDataGrid, DataGrid } from '@featuring-corp/data-grid';
import type { SortingState, ColumnFiltersState, PaginationState } from '@featuring-corp/data-grid';
function ServerGrid() {
const [rows, setRows] = useState<Row[]>([]);
const [total, setTotal] = useState(0);
const [loading, setLoading] = useState(true);
const [pagination, setPagination] = useState<PaginationState>({ pageIndex: 0, pageSize: 20 });
const [sorting, setSorting] = useState<SortingState>([]);
// 상태가 바뀌면 서버에 재요청 — 네트워크 레이어는 소비자 코드
useEffect(() => {
setLoading(true);
fetchRows({
pageIndex: pagination.pageIndex,
pageSize: pagination.pageSize,
sort: sorting.map(s => ({ field: s.id, dir: s.desc ? 'desc' : 'asc' })),
}).then(res => {
setRows(res.items);
setTotal(res.total);
setLoading(false);
});
}, [pagination.pageIndex, pagination.pageSize, sorting]);
const table = useDataGrid<Row>({
data: rows,
columns,
getCoreRowModel: getCoreRowModel(),
getRowId: r => r.id,
// manual 플래그 — TanStack이 클라이언트에서 행을 재정렬/자르지 않음
manualPagination: true,
manualSorting: true,
manualFiltering: true,
// 서버 total — aria-rowindex 절대좌표·getPageCount에 사용
rowCount: total,
// 현재 state를 외부에서 주입(controlled)
state: { pagination, sorting },
// 변경을 소비자 state setter로 중계 — 다음 effect가 재요청을 트리거
onPaginationChange: setPagination,
onSortingChange: u => setSorting(prev => typeof u === 'function' ? u(prev) : u),
loading,
skeletonRows: pagination.pageSize,
});
return (
<DataGrid.Root table={table}>
<DataGrid.Header />
<DataGrid.Body />
<DataGrid.Empty>결과가 없습니다.</DataGrid.Empty>
<DataGrid.Error>데이터를 불러오지 못했습니다.</DataGrid.Error>
</DataGrid.Root>
);
}rowCount는 TanStack Table의 표준 옵션입니다. getPageCount()가 이 값으로 총 페이지 수를 계산하고,
그리드는 각 행의 aria-rowindex에 페이지 오프셋을 더해 절대좌표를 유지합니다.
manual 플래그 요약
| 옵션 | 효과 |
|---|---|
manualSorting: true | getSortedRowModel 비활성. onSortingChange로만 알림 |
manualFiltering: true | getFilteredRowModel 비활성. onColumnFiltersChange로만 알림 |
manualPagination: true | getPaginationRowModel 비활성. onPaginationChange로만 알림. rowCount 필수 |
로딩·에러 표면
fetch 진행 중 그리드가 레이아웃을 유지하도록 loading/skeletonRows를 연결합니다.
에러가 발생하면 error에 에러 객체를 넘깁니다. 세 옵션 모두 useDataGrid에 전달하며, <DataGrid.Root>는
이 값을 받지 않습니다.
const table = useDataGrid<Row, ApiError>({
data: query.data ?? [],
columns,
getCoreRowModel: getCoreRowModel(),
loading: query.isLoading, // true → Body가 skeleton을 렌더
error: query.error ?? null, // truthy → <DataGrid.Error> 렌더
skeletonRows: pagination.pageSize, // loading 중 몇 행 자리를 차지할지
});Prop
Type
skeletonRows를 pagination.pageSize와 같게 설정하면 fetch 전후 그리드 높이가 유지되어 layout shifting이 없습니다.
skeleton 셀의 모양은 컬럼 meta.skeleton으로 정의합니다. 자세한 내용은 View state를 참고하세요.
서버 편집 (CRUD)
useDataGridEditing의 onCellEdit가 Promise<void>를 반환하면 그리드는 per-cell saveStatus를 자동 관리합니다.
resolve되면 히스토리에 기록하고, reject되면 미기록 + 해당 셀 saveStatus='error'가 됩니다.
권장 패턴은 낙관적 업데이트 + 실패 시 rollback입니다. onCellEdit 진입 즉시 표시 데이터를 새 값으로 갱신한 뒤
서버 PATCH를 호출하고, 실패하면 이전 값(oldValue)으로 되돌립니다.
const editing = useDataGridEditing(table, {
selection,
onCellEdit: async ({ coord, newValue, oldValue }) => {
// 낙관적 즉시 반영
setRows(prev =>
prev.map(r => r.id === coord.rowId ? { ...r, [coord.columnId]: newValue } : r)
);
try {
await api.patch(coord.rowId, coord.columnId, newValue);
} catch (err) {
// 실패 — 이전 값으로 rollback
setRows(prev =>
prev.map(r => r.id === coord.rowId ? { ...r, [coord.columnId]: oldValue } : r)
);
toast.add({
status: 'error',
title: '저장 실패',
description: err instanceof Error ? err.message : '잠시 후 다시 시도해 주세요.',
});
throw err; // re-throw → 그리드가 미기록 + saveStatus='error'로 인식
}
},
});저장 중인 셀에서 saveStatus를 읽으려면 useDataGridCellEdit를 사용합니다.
function EditableCell({ info }: { info: CellContext<Row, unknown> }) {
const { saveStatus } = useDataGridCellEdit(info.cell);
// saveStatus: 'idle' | 'pending' | 'error'
return (
<DataGrid.Cell cell={info.cell}>
<DataGrid.CellDisplay>
{/* saveStatus로 pending 스피너·error 강조 표시 */}
{info.getValue() as string}
</DataGrid.CellDisplay>
<DataGrid.CellEditor>
<DataGrid.TextEditor />
</DataGrid.CellEditor>
</DataGrid.Cell>
);
}editing.isAnyPending으로 그리드 전체에 진행 중 저장이 하나라도 있는지 확인할 수 있습니다.
저장 실패 사유나 재시도 UI는 그리드가 표면화하지 않습니다 — 위 예시처럼 토스트 등으로 소비자가 직접 처리합니다(셀은 compact 유지).
onCellEdit의 void vs Promise<void> 반환 차이(record-on-commit vs record-on-confirm)와 TextEditor의 parse 옵션은
편집 모델에서 자세히 다룹니다.
행 추가·삭제
그리드는 행 추가·삭제 API를 제공하지 않습니다. 소비자가 rows state를 직접 변경하면 그리드는 새 배열을 받아 다시 렌더합니다.
서버 CRUD도 같은 방식입니다 — 서버 요청을 먼저 보내고(또는 낙관적으로 state를 먼저 바꾸고) 응답을 받아 state를 확정합니다.
// 행 추가 — 낙관적 패턴
async function handleAddRow(draft: Omit<Row, 'id'>) {
const tempId = `temp-${Date.now()}`;
// 즉시 추가
setRows(prev => [...prev, { ...draft, id: tempId }]);
setTotal(prev => prev + 1);
try {
const created = await api.createRow(draft);
// 서버 확정값으로 교체
setRows(prev => prev.map(r => r.id === tempId ? created : r));
} catch {
// 실패 — 제거
setRows(prev => prev.filter(r => r.id !== tempId));
setTotal(prev => prev - 1);
}
}
// 행 삭제 — 낙관적 패턴
async function handleDeleteRow(rowId: string) {
const snapshot = rows;
setRows(prev => prev.filter(r => r.id !== rowId));
setTotal(prev => prev - 1);
try {
await api.deleteRow(rowId);
} catch {
// 실패 — 복원
setRows(snapshot);
setTotal(prev => prev + 1);
}
}getRowId: r => r.id를 useDataGrid에 지정하면 행 순서가 바뀌거나 새 행이 삽입돼도
그리드가 row identity를 안정적으로 추적합니다. 서버가 생성한 실제 ID로 교체할 때 리렌더가 최소화됩니다.
다음 단계
- 페이지네이션 —
getPageCount()·절대aria-rowindex·manualPagination상세. - 편집 모델 —
onCellEdit전체 lifecycle,record-on-confirm,TextEditor, undo/redo. - View state —
loading/error/skeletonRows·skeleton 컬럼 소유 패턴·뷰 슬롯.
라이브 데모:
- Server Side 스토리 —
manualSorting/manualFiltering/manualPagination+ AG Grid 스타일 컬럼 메뉴 + Airtable 스타일 툴바 두 가지 UX. - Server CRUD 스토리 — 서버 READ + 비동기 WRITE 풀스코프 합성. 낙관적 업데이트·rollback·토스트 흐름.