Skip to main content

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:

  • register is wrapped in useRef to 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:

  1. Extend FilterNode and implement render() with your filter UI
  2. Instantiate the node with useRef to keep a stable reference
  3. Call useRegisterPlugin with a unique field, a display label, and the node instance
  4. 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)