Featuring Design System
Layout & Scale

Server-Side Data

정렬·필터·페이지네이션을 서버에 위임하고, 로딩·에러를 인스턴스에 연결하며, 비동기 편집으로 CRUD를 완성합니다.

개요

그리드는 현재 상태(정렬 키·필터 값·페이지 번호)를 알릴 뿐, 행을 직접 정렬하거나 자르지 않습니다. 소비자가 그 상태를 fetch 파라미터로 변환해 서버에 넘기고, 서버가 돌려준 행과 total을 그리드에 다시 주입합니다. 그리드는 주입된 행을 그대로 렌더링합니다.

이 방식의 핵심 속성 세 가지입니다.

  • 절대 aria-rowindexrowCount로 전체 행 수를 알려주면, 페이지를 넘겨도 보조기술이 "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: truegetSortedRowModel 비활성. onSortingChange로만 알림
manualFiltering: truegetFilteredRowModel 비활성. onColumnFiltersChange로만 알림
manualPagination: truegetPaginationRowModel 비활성. 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

skeletonRowspagination.pageSize와 같게 설정하면 fetch 전후 그리드 높이가 유지되어 layout shifting이 없습니다. skeleton 셀의 모양은 컬럼 meta.skeleton으로 정의합니다. 자세한 내용은 View state를 참고하세요.


서버 편집 (CRUD)

useDataGridEditingonCellEditPromise<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 유지).

onCellEditvoid vs Promise<void> 반환 차이(record-on-commit vs record-on-confirm)와 TextEditorparse 옵션은 편집 모델에서 자세히 다룹니다.


행 추가·삭제

그리드는 행 추가·삭제 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.iduseDataGrid에 지정하면 행 순서가 바뀌거나 새 행이 삽입돼도 그리드가 row identity를 안정적으로 추적합니다. 서버가 생성한 실제 ID로 교체할 때 리렌더가 최소화됩니다.


다음 단계

  • 페이지네이션getPageCount()·절대 aria-rowindex·manualPagination 상세.
  • 편집 모델onCellEdit 전체 lifecycle, record-on-confirm, TextEditor, undo/redo.
  • View stateloading/error/skeletonRows·skeleton 컬럼 소유 패턴·뷰 슬롯.

라이브 데모:

  • Server Side 스토리manualSorting/manualFiltering/manualPagination + AG Grid 스타일 컬럼 메뉴 + Airtable 스타일 툴바 두 가지 UX.
  • Server CRUD 스토리 — 서버 READ + 비동기 WRITE 풀스코프 합성. 낙관적 업데이트·rollback·토스트 흐름.