Featuring Design System
Columns

Filtering

전역 검색·컬럼 필터·패싯 통계·커스텀 filterFn·서버 위임 — 필터 UI는 소비자가 조립한다.

개요

필터링은 TanStack Table의 기능입니다. 패키지는 필터 UI(텍스트 박스, 드롭다운, 체크박스, 범위 입력)를 소유하지 않고, 데이터 모델 진입점만 표준화합니다.

세 진입점이 있습니다.

  • 전역 검색state.globalFilter + onGlobalFilterChange. 모든 컬럼을 가로질러 검색어와 매칭합니다.
  • 컬럼 필터column.getFilterValue() / column.setFilterValue() + state.columnFilters. 컬럼별 독립 필터입니다.
  • 패싯 통계column.getFacetedUniqueValues()Map<값, 건수>, column.getFacetedMinMaxValues()[min, max]. 옵션 목록과 범위 경계를 동적으로 계산합니다.

row model factory(getFilteredRowModel, getFacetedRowModel, getFacetedUniqueValues, getFacetedMinMaxValues)는 패키지 루트에서 re-export됩니다. @tanstack/react-table을 직접 import하지 않아도 됩니다.

import {
  getFilteredRowModel,
  getFacetedRowModel,
  getFacetedUniqueValues,
  getFacetedMinMaxValues,
} from '@featuring-corp/data-grid';

getFilteredRowModel을 row model에 넣으면 기본(client) 모드로 필터가 자동 적용됩니다. 서버에서 필터링하려면 manualFiltering: true를 켜고 이미 좁혀진 데이터를 직접 넘깁니다.


전역 필터

state.globalFilter + onGlobalFilterChange를 연결하면 모든 컬럼을 가로질러 검색합니다. getFilteredRowModel을 row model에 포함하면 table.getFilteredRowModel().rows가 자동으로 좁혀집니다. 결과가 0건이면 <DataGrid.Empty>가 렌더됩니다.

const data = [
{ id: '1', name: '김민준', platform: 'youtube', followers: 128000 },
{ id: '2', name: '이서연', platform: 'instagram', followers: 84500 },
{ id: '3', name: '박도윤', platform: 'tiktok', followers: 233000 },
{ id: '4', name: '최하은', platform: 'youtube', followers: 45200 },
{ id: '5', name: '정시우', platform: 'instagram', followers: 67800 },
{ id: '6', name: '강지우', platform: 'tiktok', followers: 152000 },
];

const columns = [
{
  accessorKey: 'name',
  header: ({ header }) => <DataGrid.HeaderCell header={header}>채널</DataGrid.HeaderCell>,
  cell: (info) => <DataGrid.Cell cell={info.cell}>{info.getValue()}</DataGrid.Cell>,
},
{
  accessorKey: 'platform',
  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 GlobalFilterGrid() {
const [globalFilter, setGlobalFilter] = useState('');

const table = useDataGrid({
  data,
  columns,
  getCoreRowModel: getCoreRowModel(),
  getSortedRowModel: getSortedRowModel(),
  getFilteredRowModel: getFilteredRowModel(),
  getRowId: (row) => row.id,
  state: { globalFilter },
  onGlobalFilterChange: setGlobalFilter,
});

const filteredCount = table.getFilteredRowModel().rows.length;

return (
  <VStack $css={{ gap: '$spacing-300' }}>
    <HStack $css={{ gap: '$spacing-300', alignItems: 'center' }}>
      <TextInput.Root $css={{ flex: 1, maxWidth: '320px' }}>
        <TextInput.Input
          aria-label="Search"
          placeholder="검색..."
          value={globalFilter}
          onChange={(e) => setGlobalFilter(e.target.value)}
        />
      </TextInput.Root>
      <Typo variant="$caption-1" $css={{ color: '$text-3' }}>
        {filteredCount}
      </Typo>
    </HStack>

    <DataGrid.Root table={table}>
      <DataGrid.Header />
      <DataGrid.Body />
      <DataGrid.Empty>
        <Typo variant="$heading-2">검색 결과가 없습니다</Typo>
      </DataGrid.Empty>
    </DataGrid.Root>
  </VStack>
);
}

render(<GlobalFilterGrid />);

컬럼 필터

컬럼 정의에 filterFn을 지정하고, 헤더 render-prop 안에서 column.getFilterValue()column.setFilterValue()로 상태를 읽고 씁니다.

필터 row 패턴 (Excel / AG Grid 스타일)

헤더 아래에 별도의 입력 row를 두는 패턴입니다. DataGrid.Header의 render-prop에서 일반 헤더 row와 필터 row를 각각 렌더합니다.

const data = [
{ id: '1', name: '김민준', platform: 'youtube', followers: 128000 },
{ id: '2', name: '이서연', platform: 'instagram', followers: 84500 },
{ id: '3', name: '박도윤', platform: 'tiktok', followers: 233000 },
{ id: '4', name: '최하은', platform: 'youtube', followers: 45200 },
{ id: '5', name: '정시우', platform: 'instagram', followers: 67800 },
{ id: '6', name: '강지우', platform: 'tiktok', followers: 152000 },
];

const columns = [
{
  accessorKey: 'name',
  header: ({ header }) => <DataGrid.HeaderCell header={header}>채널</DataGrid.HeaderCell>,
  cell: (info) => <DataGrid.Cell cell={info.cell}>{info.getValue()}</DataGrid.Cell>,
},
{
  accessorKey: 'platform',
  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 ColumnFilterGrid() {
const [columnFilters, setColumnFilters] = useState([]);

const table = useDataGrid({
  data,
  columns,
  getCoreRowModel: getCoreRowModel(),
  getFilteredRowModel: getFilteredRowModel(),
  getRowId: (row) => row.id,
  state: { columnFilters },
  onColumnFiltersChange: setColumnFilters,
});

return (
  <DataGrid.Root table={table}>
    <DataGrid.Header>
      {(headerGroup) => (
        <>
          {/* 일반 헤더 row */}
          <DataGrid.HeaderRow>
            {headerGroup.headers.map((header) =>
              header.isPlaceholder ? null : (
                <React.Fragment key={header.id}>
                  {flexRender(header.column.columnDef.header, header.getContext())}
                </React.Fragment>
              ),
            )}
          </DataGrid.HeaderRow>

          {/* 필터 row — cursor만 override */}
          <DataGrid.HeaderRow>
            {headerGroup.headers.map((header) => (
              <DataGrid.HeaderCell
                key={header.id + '-filter'}
                column={header.column}
                $css={{ cursor: 'default' }}
              >
                {header.column.getCanFilter() ? (
                  <TextInput.Root size="sm">
                    <TextInput.Input
                      aria-label={'Filter ' + header.column.id}
                      placeholder="필터"
                      value={header.column.getFilterValue() ?? ''}
                      onChange={(e) => header.column.setFilterValue(e.target.value)}
                    />
                  </TextInput.Root>
                ) : null}
              </DataGrid.HeaderCell>
            ))}
          </DataGrid.HeaderRow>
        </>
      )}
    </DataGrid.Header>
    <DataGrid.Body />
  </DataGrid.Root>
);
}

render(<ColumnFilterGrid />);

빌트인 filterFn

컬럼 정의의 filterFn 필드에 문자열로 지정합니다.

filterFn동작
'includesString'대소문자 무시 부분 일치 (기본값)
'includesStringSensitive'대소문자 구분 부분 일치
'equalsString'대소문자 무시 정확 일치
'equals'엄격 동일(===)
'weakEquals'느슨한 동일(==) — 숫자·문자열 혼용 ID에 유용
'arrIncludesSome'배열 셀이 필터 배열과 하나 이상 교집합
'arrIncludesAll'배열 셀이 필터 배열을 모두 포함
'inNumberRange'[min, max] 범위 (빈 칸은 ±∞)
// 컬럼 정의에서
const columns: ColumnDef<Row>[] = [
  {
    accessorKey: 'name',
    filterFn: 'includesString', // 기본 — 대소문자 무시 부분 일치
  },
  {
    accessorKey: 'platform',
    filterFn: 'equalsString',   // select 단일 값 필터에 적합
  },
  {
    id: 'tags',
    accessorKey: 'tags',        // 배열 컬럼
    filterFn: 'arrIncludesSome',
  },
  {
    accessorKey: 'cost',
    filterFn: 'inNumberRange',  // setFilterValue([min, max])
  },
];

패싯 필터

패싯은 현재 다른 필터들이 적용된 뒤 남은 집합에서 옵션과 통계를 계산합니다. 점진적으로 필터를 좁혀가면 옵션 건수가 실시간으로 업데이트됩니다.

패싯을 쓰려면 getFacetedRowModel이 필수이고, 패싯 종류에 따라 getFacetedUniqueValues 또는 getFacetedMinMaxValues를 추가합니다.

const table = useDataGrid({
  getCoreRowModel: getCoreRowModel(),
  getFilteredRowModel: getFilteredRowModel(),
  getFacetedRowModel: getFacetedRowModel(),         // 패싯 공통 필수
  getFacetedUniqueValues: getFacetedUniqueValues(), // set 필터용
  getFacetedMinMaxValues: getFacetedMinMaxValues(), // 범위 필터용
  // ...
});

Set 필터 — getFacetedUniqueValues

column.getFacetedUniqueValues()Map<값, 건수>를 돌려줍니다. 이 Map으로 Select나 Checkbox.Group의 옵션 목록을 만들고, 라벨 뒤에 건수를 붙여 "현재 몇 건이 매칭되는지" 미리 보여줄 수 있습니다.

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

const columns = [
{
  accessorKey: 'name',
  header: ({ header }) => <DataGrid.HeaderCell header={header}>채널</DataGrid.HeaderCell>,
  cell: (info) => <DataGrid.Cell cell={info.cell}>{info.getValue()}</DataGrid.Cell>,
},
{
  accessorKey: 'platform',
  filterFn: 'equalsString',
  header: ({ header }) => {
    const facets = header.column.getFacetedUniqueValues(); // Map<string, number>
    const value = header.column.getFilterValue() ?? '';
    return (
      <DataGrid.HeaderCell header={header} $css={{ cursor: 'default', alignItems: 'center', gap: '$spacing-200' }}>
        플랫폼
        <Select.Root
          size="sm"
          value={value || '__all__'}
          onValueChange={(next) =>
            header.column.setFilterValue(next === '__all__' ? undefined : next)
          }
        >
          <Select.Trigger $css={{ minWidth: '120px' }}>
            <Select.Value />
          </Select.Trigger>
          <Select.Portal>
            <Select.Positioner>
              <Select.Popup>
                <Select.Item value="__all__">
                  <Select.ItemText>전체</Select.ItemText>
                </Select.Item>
                {['youtube', 'instagram', 'tiktok'].map((p) => (
                  <Select.Item key={p} value={p}>
                    <Select.ItemText>
                      {p} ({facets.get(p) ?? 0})
                    </Select.ItemText>
                  </Select.Item>
                ))}
              </Select.Popup>
            </Select.Positioner>
          </Select.Portal>
        </Select.Root>
      </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 FacetedSelectGrid() {
const [columnFilters, setColumnFilters] = useState([]);
const table = useDataGrid({
  data,
  columns,
  getCoreRowModel: getCoreRowModel(),
  getFilteredRowModel: getFilteredRowModel(),
  getFacetedRowModel: getFacetedRowModel(),
  getFacetedUniqueValues: getFacetedUniqueValues(),
  getRowId: (row) => row.id,
  state: { columnFilters },
  onColumnFiltersChange: setColumnFilters,
});
return (
  <DataGrid.Root table={table}>
    <DataGrid.Header />
    <DataGrid.Body />
  </DataGrid.Root>
);
}

render(<FacetedSelectGrid />);

다중 선택(Checkbox.Group)은 filterFn: 'arrIncludesSome'과 함께 씁니다. accessorFn이 배열을 반환해야 TanStack이 올바르게 매칭합니다.

{
  id: 'category',
  accessorFn: (row) => [row.category], // 단일 값도 배열로 감싸야 arrIncludesSome 동작
  filterFn: 'arrIncludesSome',
  // setFilterValue(string[]) — 빈 배열은 undefined로
}

수치 범위 필터 — getFacetedMinMaxValues

column.getFacetedMinMaxValues()[min, max] | undefined를 돌려줍니다. 이 경계를 placeholder나 안내 텍스트로 노출하고, 두 TextInput을 inNumberRange filterFn에 연결합니다.

const data = [
{ id: '1', name: '김민준', cost: 1800000 },
{ id: '2', name: '이서연', cost: 950000 },
{ id: '3', name: '박도윤', cost: 3200000 },
{ id: '4', name: '최하은', cost: 540000 },
{ id: '5', name: '정시우', cost: 1250000 },
{ id: '6', name: '강지우', cost: 2100000 },
{ id: '7', name: '윤서준', cost: 430000 },
{ id: '8', name: '임하준', cost: 1500000 },
];

const columns = [
{
  accessorKey: 'name',
  header: ({ header }) => <DataGrid.HeaderCell header={header}>채널</DataGrid.HeaderCell>,
  cell: (info) => <DataGrid.Cell cell={info.cell}>{info.getValue()}</DataGrid.Cell>,
},
{
  accessorKey: 'cost',
  filterFn: 'inNumberRange',
  header: ({ header }) => {
    const [facetMin, facetMax] = header.column.getFacetedMinMaxValues() ?? [0, 0];
    const range = header.column.getFilterValue() ?? [null, null];
    const setBound = (idx, raw) => {
      const v = raw === '' ? null : Number(raw);
      const next = idx === 0 ? [v, range[1]] : [range[0], v];
      header.column.setFilterValue(
        next[0] === null && next[1] === null ? undefined : next,
      );
    };
    return (
      <DataGrid.HeaderCell header={header} $css={{ cursor: 'default', alignItems: 'center', gap: '$spacing-200' }}>
        단가(₩)
        <HStack $css={{ gap: '$spacing-100', alignItems: 'center' }}>
          <TextInput.Root size="sm">
            <TextInput.Input
              aria-label="Minimum"
              placeholder={'min ' + facetMin}
              value={range[0] === null ? '' : String(range[0])}
              onChange={(e) => setBound(0, e.target.value)}
            />
          </TextInput.Root>
          <Typo variant="$caption-1" $css={{ color: '$text-3' }}>~</Typo>
          <TextInput.Root size="sm">
            <TextInput.Input
              aria-label="Maximum"
              placeholder={'max ' + facetMax}
              value={range[1] === null ? '' : String(range[1])}
              onChange={(e) => setBound(1, e.target.value)}
            />
          </TextInput.Root>
        </HStack>
      </DataGrid.HeaderCell>
    );
  },
  cell: (info) => <DataGrid.Cell cell={info.cell}>{info.getValue().toLocaleString()}</DataGrid.Cell>,
},
];

function FacetedRangeGrid() {
const [columnFilters, setColumnFilters] = useState([]);
const table = useDataGrid({
  data,
  columns,
  getCoreRowModel: getCoreRowModel(),
  getFilteredRowModel: getFilteredRowModel(),
  getFacetedRowModel: getFacetedRowModel(),
  getFacetedMinMaxValues: getFacetedMinMaxValues(),
  getRowId: (row) => row.id,
  state: { columnFilters },
  onColumnFiltersChange: setColumnFilters,
});
return (
  <DataGrid.Root table={table}>
    <DataGrid.Header />
    <DataGrid.Body />
  </DataGrid.Root>
);
}

render(<FacetedRangeGrid />);

빈 칸은 ±∞로 처리됩니다(inNumberRange 기본 동작).


커스텀 filterFn

빌트인으로 표현할 수 없는 로직은 (row, columnId, filterValue) => boolean 함수를 직접 씁니다.

{
  accessorKey: 'tags',
  filterFn: (row, columnId, fv: { mode: 'any' | 'all'; tags: string[] } | undefined) => {
    if (!fv || fv.tags.length === 0) return true;
    const cell = row.getValue<string[]>(columnId) ?? [];
    return fv.mode === 'all'
      ? fv.tags.every((t) => cell.includes(t))  // arrIncludesAll 동형
      : fv.tags.some((t) => cell.includes(t));   // arrIncludesSome 동형
  },
}

filterValue의 shape은 setFilterValue()에 넘기는 값과 동일합니다. 필터가 없는 상태(undefined)를 첫 줄에서 early-return해야 합니다.

AG Grid 스타일 연산자 필터(=, ≠, >, ≥, <, ≤, between)도 같은 패턴으로 구현합니다. filterValue{ op, value, value2 }를 담고, switch (op) 분기로 비교합니다.


서버 필터 (manual)

manualFiltering: true를 지정하면 TanStack이 client 측 필터를 적용하지 않습니다. onColumnFiltersChange로 받은 columnFilters 상태를 fetch 파라미터로 서버에 흘리고, 패키지는 돌아온 행만 그립니다.

function ServerFilterGrid({ columns }) {
  const [columnFilters, setColumnFilters] = useState<ColumnFiltersState>([]);

  // columnFilters를 fetch 파라미터로 서버에 전달
  const { data, isLoading, error } = useFetch({ columnFilters });
  const items = useMemo(() => data?.items ?? [], [data]);

  const table = useDataGrid({
    getCoreRowModel: getCoreRowModel(),
    getFilteredRowModel: getFilteredRowModel(),
    getFacetedRowModel: getFacetedRowModel(),
    getFacetedUniqueValues: getFacetedUniqueValues(),
    data: items,
    columns,
    rowCount: data?.total,   // server total — client 필터를 비활성화하는 신호
    manualFiltering: true,   // client 필터 적용 안 함
    state: { columnFilters },
    onColumnFiltersChange: setColumnFilters,
    loading: isLoading,
    error,
    skeletonRows: 10,
  });

  return (
    <DataGrid.Root table={table}>
      <DataGrid.Header />
      <DataGrid.Body />
      <DataGrid.Empty>
        <Typo variant="$heading-2">조건에 맞는 결과가 없습니다</Typo>
      </DataGrid.Empty>
    </DataGrid.Root>
  );
}

globalFilter도 같은 방식으로 서버에 위임할 수 있습니다 — state: { globalFilter } + onGlobalFilterChange를 연결하고, 검색어를 fetch 파라미터로 흘립니다.

Prop

Type


다음 단계

  • Sorting — 필터와 함께 동작하는 정렬.
  • Pagination — 필터 적용 후 행 수에 따른 페이지 수 자동 재계산.
  • ColumnsfilterFn이 포함된 컬럼 정의 전체 API.
  • 라이브 예제 — Storybook에서 전역 필터·컬럼 필터·패싯·커스텀·서버 필터를 직접 확인.