Composed Filter Pattern
Architecture pattern for building a composable filter system in React where filter plugins register themselves into a central composer.
🎯 Problem​
In a complex application, managing multiple filters can become painful:
- Filter logic scattered across different components
- Hard to add or remove filters without modifying the parent component
- Duplicated state management for applied/cleared filters
- No standard way to render filter UI (menu, pills, overlay)
📂 Architecture​
src/
├── features/
│ └── filters/
│ ├── FilterComposer.tsx
│ ├── types.ts
│ ├── utils.ts
│ ├── context/
│ │ ├── FilterComposerContext.ts
│ │ └── useRegisterPlugin.ts
│ ├── plugins/
│ │ ├── DatasetTypeFilter.tsx
│ │ ├── ContributorsFilter.tsx
│ │ └── DatasetSourceOriginFilter.tsx
│ └── ui/
│ └── CheckBoxFilterComponent.tsx
📜 Implementation​
1. Types​
Define the base types in types.ts:
export abstract class FilterNode<T = any> {
abstract render(filter: T, onFilterUpdate: (field: string, filter: T) => void): React.ReactNode;
}
export interface RegisteredPlugin {
field: string;
label: string;
node: FilterNode;
}
FilterNode is an abstract class that each filter plugin must extend. It enforces a render method that receives the current filter value and an update callback.
2. Context​
Create the context in context/FilterComposerContext.ts:
import { createContext } from 'react';
import type { RegisteredPlugin } from '../types';
interface FilterComposerContextType {
register: (plugin: RegisteredPlugin) => () => void;
}
export const FilterComposerContext = createContext<FilterComposerContextType>({
register: () => () => null,
});
And the registration hook in context/useRegisterPlugin.ts:
import React, { useEffect } from 'react';
import type { RegisteredPlugin } from '../types';
import { FilterComposerContext } from './FilterComposerContext';
export const useRegisterPlugin = (plugin: RegisteredPlugin) => {
const context = React.useContext(FilterComposerContext);
if (context === undefined) {
throw new Error('useFilterComposerContext must be used within FilterComposer');
}
const { register } = context;
useEffect(() => register(plugin), [register]);
};
Each plugin calls useRegisterPlugin on mount, which returns an unregister function automatically called on unmount via the useEffect cleanup.
3. FilterComposer​
The central component that manages registration, filter state, and rendering:
import type { PropsWithChildren } from 'react';
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { ReactComponent as FilterIcon } from '../../assets/icons/interface/ic-filter.svg';
import { Button, Dropdown, FilterMenu, FilterOverlay, FilterPills, Filters } from '../../ui';
import { FilterComposerContext } from './context';
import type { RegisteredPlugin } from './types';
import { cleanFilters } from './utils';
interface FilterComposerProps {
filters?: Record<string, any>;
nbOfVisiblePills?: number;
onFiltersUpdate?: (filters: Record<string, any>) => void;
}
const defaultFilters = {};
const sortPlugins = (a: RegisteredPlugin, b: RegisteredPlugin) => a.label.localeCompare(b.label);
export const FilterComposer = ({
children,
filters,
nbOfVisiblePills,
onFiltersUpdate,
}: PropsWithChildren<FilterComposerProps>) => {
const [appliedFilters, setAppliedFilters] = useState<Record<string, any>>(defaultFilters);
const [showFilters, setShowFilters] = useState(false);
const [selectedPlugin, setSelectedPlugin] = useState<string>();
const [registeredPlugins, setRegisteredPlugins] = useState<string[]>([]);
const [plugins, setPlugins] = useState<Record<string, RegisteredPlugin>>({});
useEffect(() => {
setAppliedFilters(filters || defaultFilters);
}, [filters]);
const isSelected = (field: string) => field === selectedPlugin;
const handleShowFilter = (field: string) => {
setSelectedPlugin(field);
setShowFilters(true);
};
const { current: register } = useRef((plugin: RegisteredPlugin) => {
setPlugins((prevState) => {
const newState = { ...prevState, [plugin.field]: plugin };
const sortedPlugins = Object.values(newState)
.sort(sortPlugins)
.map((plugin) => plugin.field);
setRegisteredPlugins(sortedPlugins);
setSelectedPlugin(sortedPlugins[0]);
return newState;
});
return () => {
setRegisteredPlugins((prevState) => {
const newState = prevState.filter((field) => field !== plugin.field);
const [firstPlugin] = newState;
setSelectedPlugin((current) => {
if (current === plugin.field) {
return firstPlugin;
}
return current;
});
return newState;
});
setPlugins((prevState) => {
const copy = { ...prevState };
delete copy[plugin.field];
return copy;
});
};
});
const handleClearFilter = useCallback(
(field?: string) => {
if (field) {
setAppliedFilters((prevState) => {
const newFilters = { ...prevState };
delete newFilters[field];
onFiltersUpdate?.(newFilters);
return newFilters;
});
}
},
[onFiltersUpdate],
);
const handleClearAllFilter = useCallback(() => {
setAppliedFilters(() => {
const newFilters = {};
onFiltersUpdate?.(newFilters);
return newFilters;
});
}, [onFiltersUpdate]);
const onFilterUpdate = useCallback(
(field: string, filter: any) => {
setAppliedFilters((prevState) => {
const newFilters = { ...prevState, [field]: filter };
if (filter === undefined) delete newFilters[field];
onFiltersUpdate?.(newFilters);
return newFilters;
});
},
[onFiltersUpdate],
);
const overlay = () => (
<FilterOverlay
menu={
<FilterMenu
filters={cleanFilters(registeredPlugins, appliedFilters)}
plugins={registeredPlugins.map((field) => plugins[field])}
isSelected={isSelected}
selectPlugin={setSelectedPlugin}
/>
}
onClearFilter={() => handleClearFilter(selectedPlugin)}
onClearAllFilters={handleClearAllFilter}
>
{selectedPlugin && plugins[selectedPlugin]?.node.render(appliedFilters[selectedPlugin], onFilterUpdate)}
</FilterOverlay>
);
return (
<FilterComposerContext.Provider value={{ register }}>
<Filters>
<Dropdown overlay={overlay()} visible={showFilters} onVisibleChange={setShowFilters} trigger="click">
<Button leftIcon={FilterIcon} variant="white">
Filters
</Button>
</Dropdown>
<FilterPills
filters={cleanFilters(registeredPlugins, appliedFilters)}
plugins={plugins}
onSelectFilter={handleShowFilter}
onClearFilter={handleClearFilter}
onClearAllFilters={handleClearAllFilter}
nbOfVisiblePills={nbOfVisiblePills}
/>
</Filters>
{children}
</FilterComposerContext.Provider>
);
};
Key design decisions:
registeris wrapped inuseRefto keep a stable reference across renders, preventing unnecessary re-registrations from child plugins- Plugins are sorted alphabetically by label for consistent menu ordering
- Unregistration is handled via the cleanup function returned by
register, which is called automatically when a plugin unmounts
4. Creating a Filter Plugin​
Each filter is a standalone component that registers itself into the composer:
import React, { useRef } from 'react';
import { getDatasetTypeOptions } from '../../../entities/utils';
import { Icon } from '../../../ui';
import { useRegisterPlugin } from '../context';
import { FilterNode } from '../types';
import { CheckBoxFilterComponent } from '../ui';
export const pluginField = 'dsTypes';
const sizeIcon = 18;
class DatasetTypeFilterNode<T> extends FilterNode<T> {
private options = getDatasetTypeOptions().map(({ icon, ...option }) => ({
...option,
icon: icon ? <Icon icon={icon} size={sizeIcon} /> : null,
}));
render(filter: any, onFilterUpdate: (field: string, filter: any) => void) {
return (
<CheckBoxFilterComponent
key={pluginField}
label="dataset-type-checkbox-group"
options={this.options}
filter={filter}
onFilterUpdate={(value) => onFilterUpdate(pluginField, value)}
/>
);
}
}
export const DatasetTypeFilter = () => {
const { current: node } = useRef(new DatasetTypeFilterNode());
useRegisterPlugin({
field: pluginField,
label: 'Dataset Type',
node,
});
return null;
};
The pattern for every filter plugin is:
- Extend
FilterNodeand implementrender()with your filter UI - Instantiate the node with
useRefto keep a stable reference - Call
useRegisterPluginwith a uniquefield, a displaylabel, and the node instance - Return
null— the plugin component renders nothing itself, it only registers into the composer
5. Usage​
function DatasetList() {
const [filters, setFilters] = useState({});
const [currentPage, setCurrentPage] = useState(1);
const onFiltersUpdate = (updatedFilters: Record<string, any>) => {
setCurrentPage(1);
setFilters(updatedFilters);
};
const { data, loading } = useQuery(DATASETS, {
variables: {
...filters,
pageIndex: currentPage,
pageSize: PAGE_SIZE,
},
});
return (
<>
<FilterComposer onFiltersUpdate={onFiltersUpdate}>
<ContributorsFilter />
<DatasetTypeFilter />
<DatasetSourceOriginFilter />
</FilterComposer>
{/* Your content that uses the filtered data */}
<DatasetTable data={data} loading={loading} />
</>
);
}
Adding or removing a filter is as simple as adding or removing a child component — no changes needed in FilterComposer itself.
✅ Benefits​
- Open/Closed principle: Add new filters without modifying the composer
- Self-contained plugins: Each filter owns its UI, options, and field mapping
- Automatic lifecycle: Registration and cleanup handled via React hooks
- Consistent UX: All filters share the same menu, pills, and overlay rendering
- Composable: Simply nest filter components as children to enable them
🧩 Possible Extensions​
- Add filter presets (saved combinations of filters)
- Support async filter options (e.g. fetched from an API)
- Add filter validation before applying
- Support dependent filters (e.g. one filter's options depend on another's value)