Featuring Design System
Grouping & Hierarchy

Grouping

TanStack 행 그룹핑·집계 — 그룹/집계/placeholder 행 렌더와 treegrid ARIA.

개요

행 그룹핑은 TanStack Table이 전담합니다. 패키지에 그룹 정책은 없습니다 — 그룹 행의 생김새·토글·집계 표기가 전부 소비자 cell 렌더 함수 안에 있습니다.

세 가지 행 타입이 생깁니다.

타입판별의미
그룹 행cell.getIsGrouped()그룹 헤더 — 묶인 값과 자식 수 표시
집계 행cell.getIsAggregated()집계 결과(sum/mean/…) 표시
placeholdercell.getIsPlaceholder()그룹 컬럼이 leaf 행에 만드는 빈 자리

연관 페이지:

  • Tree datagetSubRows로 원본 데이터 계층을 선언하는 방식. 그룹핑과 달리 부모·자식 관계가 데이터에 미리 있습니다.
  • Expansion — 행 아래 상세 패널을 펼치는 패턴. 그룹 펼침/접힘과 메커니즘이 다릅니다.

기본 사용법

useDataGridgetGroupedRowModel() + getExpandedRowModel()을 같이 넘기고, state.grouping으로 묶을 컬럼 키 배열을 선언합니다. 컬럼의 cell에서 세 플래그로 분기해 각 행 타입을 직접 그립니다.

const data = [
{ id: '1', name: '김민준', category: '뷰티', followers: 128000 },
{ id: '2', name: '이서연', category: '뷰티', followers: 84500 },
{ id: '3', name: '박도윤', category: '게임', followers: 233000 },
{ id: '4', name: '최하은', category: '게임', followers: 45200 },
{ id: '5', name: '정시우', category: '푸드', followers: 67800 },
{ id: '6', name: '강지우', category: '푸드', followers: 152000 },
{ id: '7', name: '윤서준', category: '뷰티', followers: 39100 },
{ id: '8', name: '임하준', category: '게임', followers: 98700 },
];

const columns = [
{
  accessorKey: 'category',
  header: ({ header }) => <DataGrid.HeaderCell header={header}>카테고리</DataGrid.HeaderCell>,
  cell: (info) => {
    const { cell } = info;
    return (
      <DataGrid.Cell cell={cell}>
        {cell.getIsGrouped() ? (
          // 그룹 행: 토글 버튼 + 그룹 값 + 멤버 수
          <button onClick={info.row.getToggleExpandedHandler()}>
            {info.row.getIsExpanded() ? '▾' : '▸'} {String(info.getValue())} ({info.row.subRows.length})
          </button>
        ) : cell.getIsPlaceholder() ? null : (
          // leaf의 그룹 컬럼 자리는 비움
          String(info.getValue() ?? '')
        )}
      </DataGrid.Cell>
    );
  },
},
{
  accessorKey: 'name',
  header: ({ header }) => <DataGrid.HeaderCell header={header}>채널</DataGrid.HeaderCell>,
  cell: (info) => (
    <DataGrid.Cell cell={info.cell}>
      {/* 집계·placeholder 행에는 채널명이 없으므로 비움 */}
      {info.cell.getIsAggregated() || info.cell.getIsPlaceholder() ? null : String(info.getValue() ?? '')}
    </DataGrid.Cell>
  ),
},
{
  accessorKey: 'followers',
  aggregationFn: 'sum',
  header: ({ header }) => <DataGrid.HeaderCell header={header}>팔로워</DataGrid.HeaderCell>,
  cell: (info) => {
    const { cell } = info;
    return (
      <DataGrid.Cell cell={cell} $css={cell.getIsAggregated() ? { fontWeight: '$fontWeight-bold' } : undefined}>
        {cell.getIsPlaceholder() ? null : Number(info.getValue() ?? 0).toLocaleString()}
      </DataGrid.Cell>
    );
  },
},
];

function GroupedGrid() {
const [grouping, setGrouping] = useState(['category']);
const [expanded, setExpanded] = useState(true); // 처음엔 전체 펼침

const table = useDataGrid({
  data,
  columns,
  getCoreRowModel: getCoreRowModel(),
  getGroupedRowModel: getGroupedRowModel(),
  getExpandedRowModel: getExpandedRowModel(),
  getRowId: (row) => row.id,
  state: { grouping, expanded },
  onGroupingChange: setGrouping,
  onExpandedChange: setExpanded,
});

const selection = useDataGridSelection(table);

return (
  <DataGrid.Root table={table} selection={selection} role="treegrid" size="md">
    <DataGrid.Header />
    <DataGrid.Body />
  </DataGrid.Root>
);
}

render(<GroupedGrid />);

role="treegrid"를 명시하는 이유: 그룹 행이 생기면 행 간 계층 관계가 생깁니다. treegrid를 선언해야 Rowaria-level·aria-expanded를 부착합니다. 자동으로 전환되지 않으므로 반드시 명시합니다.

cell 분기 패턴 요약

cell: (info) => {
  const { cell } = info;

  if (cell.getIsGrouped()) {
    // 그룹 행 — 토글·그룹 값·멤버 수
    return <DataGrid.Cell cell={cell}>…</DataGrid.Cell>;
  }
  if (cell.getIsAggregated()) {
    // 집계 행 — aggregationFn이 계산한 값
    return <DataGrid.Cell cell={cell}>…</DataGrid.Cell>;
  }
  if (cell.getIsPlaceholder()) {
    // 그룹 컬럼이 leaf 행에서 차지하는 빈 자리 — 보통 null 반환
    return <DataGrid.Cell cell={cell}>{null}</DataGrid.Cell>;
  }
  // 일반 leaf 행
  return <DataGrid.Cell cell={cell}>{String(info.getValue() ?? '')}</DataGrid.Cell>;
},

집계 — aggregationFn

컬럼에 aggregationFn을 선언하면 그룹 행의 집계 셀이 채워집니다. 집계는 별도 row model 없이 getGroupedRowModel 내부에서 계산됩니다.

built-in 집계 함수

TanStack이 제공하는 문자열 식별자입니다.

의미
'sum'합계
'mean'평균
'count'개수
'min'최솟값
'max'최댓값
'extent'[min, max] 배열
'median'중앙값
'unique'유일값 배열
'uniqueCount'유일값 개수
{
  accessorKey: 'costPerPost',
  aggregationFn: 'sum',   // 그룹 합계
  cell: (info) => {
    const { cell } = info;
    return (
      <DataGrid.Cell
        cell={cell}
        $css={cell.getIsAggregated() ? { fontWeight: '$fontWeight-bold' } : undefined}
      >
        {cell.getIsPlaceholder() ? null : krwFormatter.format(Number(info.getValue() ?? 0))}
      </DataGrid.Cell>
    );
  },
}

커스텀 집계 함수

built-in으로 표현할 수 없는 도메인 규칙은 함수를 직접 넘깁니다. 시그니처는 (columnId: string, leafRows: Row[], childRows: Row[]) => unknown입니다. leafRows는 그룹의 최하위 행, childRows는 직접 자식 행입니다.

// 팔로워 가중 참여율 = Σ(rate × followers) / Σ(followers)
const weightedEngagement = (
  _columnId: string,
  leafRows: TanstackRow<MyRow>[],
): number => {
  let weightSum = 0;
  let weighted = 0;
  leafRows.forEach((row) => {
    const rate = row.getValue<number>('engagementRate');
    const followers = row.original.followers; // 표시 컬럼이 아닌 값은 row.original에서 읽음
    weighted += rate * followers;
    weightSum += followers;
  });
  return weightSum ? weighted / weightSum : 0;
};

{
  accessorKey: 'engagementRate',
  aggregationFn: weightedEngagement, // 함수 직접 전달
  cell: (info) => { … }
}

TanstackRow@featuring-corp/data-grid에서 임포트합니다.

import type { TanstackRow } from '@featuring-corp/data-grid';

그룹 펼치기/접기

getExpandedRowModel()을 추가하고 state.expanded + onExpandedChange를 배선하면 그룹 행을 접고 펼칠 수 있습니다. 개별 행 토글과 전체 토글 두 패턴이 있습니다.

개별 행 토글

row.getToggleExpandedHandler()를 토글 버튼의 onClick에 연결합니다.

<button
  onClick={row.getToggleExpandedHandler()}
  aria-expanded={row.getIsExpanded()}
  aria-label={row.getIsExpanded() ? 'Collapse group' : 'Expand group'}
>
  {row.getIsExpanded() ? '▾' : '▸'}
</button>

onPointerDown에서 e.stopPropagation()을 호출해 그리드의 셀 선택 시작 동작과 충돌을 막습니다.

<IconButton
  onClick={row.getToggleExpandedHandler()}
  onPointerDown={(e) => e.stopPropagation()}
  aria-expanded={row.getIsExpanded()}
>

</IconButton>

전체 펼치기/접기

table.toggleAllRowsExpanded(true/false)로 모든 그룹을 한 번에 제어합니다.

<button onClick={() => grid.toggleAllRowsExpanded(true)}>전체 펼치기</button>
<button onClick={() => grid.toggleAllRowsExpanded(false)}>전체 접기</button>

초기 상태

ExpandedStatetrue로 초기화하면 모든 그룹이 처음부터 펼쳐집니다.

const [expanded, setExpanded] = useState<ExpandedState>(true);

특정 그룹만 열려면 행 id를 키로 하는 객체를 사용합니다.

const [expanded, setExpanded] = useState<ExpandedState>({ 'group-beauty': true });

다중 그룹핑 키

state.grouping에 여러 키를 넘으면 중첩 그룹(nested group)이 만들어집니다. 배열 순서가 그룹핑 순서입니다.

const [grouping, setGrouping] = useState<GroupingState>(['category', 'status']);
// → 카테고리로 묶은 뒤, 그 안에서 상태로 한 번 더 묶음

각 depth에서 집계가 독립적으로 다시 계산됩니다. treegrid를 선언하면 바깥 그룹은 aria-level=1, 안쪽 그룹은 aria-level=2, leaf는 aria-level=3으로 노출됩니다.

접근성 — treegrid

그룹 행이 생기면 반드시 Rootrole="treegrid"를 명시합니다. 이 역할이 선언될 때만 Row가 계층 ARIA를 부착합니다.

<DataGrid.Root table={grid} selection={selection} role="treegrid">

Row가 부착하는 속성은 다음과 같습니다.

속성부착 조건
aria-levelrow.depth + 1 (1-based)treegrid의 모든 행
aria-expandedtrue / false확장 가능 행만 (row.getCanExpand()) — leaf는 미부착
aria-posinsetrow.index + 1 (1-based)중첩 행만 (부모가 있는 행)
aria-setsize부모 subRows.length중첩 행만

그룹 행도 일반 행처럼 visible row model에 포함되므로 셀 선택·키보드 내비·클립보드 복사에 동등하게 참여합니다.

접근성 전반(role 기본값 규칙, 절대 좌표, 로케일 중립 정책)은 Accessibility에서 다룹니다.

API

Prop

Type

다음 단계

  • Tree data — 원본 데이터에 부모·자식 관계가 있을 때 getSubRows로 계층을 선언하는 방식.
  • Expansion — 행 아래 상세 패널(row detail)을 펼치는 컴포지션 패턴.
  • Accessibilityrole 선택 규칙, treegrid 계층 ARIA, 로케일 중립 정책 전체.
  • 라이브 예제: Storybook — DataGrid/Grouping