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 — 필터 적용 후 행 수에 따른 페이지 수 자동 재계산.
- Columns —
filterFn이 포함된 컬럼 정의 전체 API. - 라이브 예제 — Storybook에서 전역 필터·컬럼 필터·패싯·커스텀·서버 필터를 직접 확인.