ProductFilter
Description
A headless render props component for building product filtering interfaces. It manages filter state, URL synchronization, and provides helper functions for attribute, category, and price filtering — but renders no UI of its own.
Role in Theming
ProductFilter is one of EverShop's headless components — it owns the filtering logic while leaving all UI decisions to its parent:
- ProductFilter renders nothing. It returns only what its
childrenfunction renders. - Theme developers do not override ProductFilter. Instead, they override the parent components that consume it (
CategoryProductsFilterand search filter components). - The logic stays stable across themes. URL parameter management, multi-value filter handling, page reset on filter change, and GraphQL refetching are all encapsulated in ProductFilter.
Theme Override Points
ProductFilter is consumed by these components, which are the actual override targets for theme developers:
| Parent Component | Route | Override Path in Theme |
|---|---|---|
| CategoryProductsFilter | categoryView | themes/<name>/src/pages/categoryView/CategoryProductsFilter.tsx |
| SearchProductsFilter | catalogSearch | themes/<name>/src/pages/catalogSearch/SearchProductsFilter.tsx |
Import
import { ProductFilter } from '@components/frontStore/catalog/ProductFilter';
Usage
import { ProductFilter } from '@components/frontStore/catalog/ProductFilter';
function Filters({ currentFilters, availableAttributes, priceRange }) {
return (
<ProductFilter
currentFilters={currentFilters}
availableAttributes={availableAttributes}
priceRange={priceRange}
>
{({ addFilter, removeFilter, isOptionSelected }) => (
<div>
{availableAttributes.map(attr => (
<div key={attr.attributeCode}>
<h3>{attr.attributeName}</h3>
{attr.options.map(option => (
<label key={option.optionId}>
<input
type="checkbox"
checked={isOptionSelected(attr.attributeCode, option.optionId.toString())}
onChange={() => addFilter(attr.attributeCode, 'in', option.optionId.toString())}
/>
{option.optionText}
</label>
))}
</div>
))}
</div>
)}
</ProductFilter>
);
}
Props
| Name | Type | Required | Description |
|---|---|---|---|
| currentFilters | FilterInput[] | Yes | Active filters array |
| availableAttributes | FilterableAttribute[] | No | Filterable product attributes |
| priceRange | PriceRange | Yes | Min/max price range |
| categories | CategoryFilter[] | No | Available categories |
| setting | object | No | Store settings (language, currency) |
| onFilterUpdate | (filters: FilterInput[]) => void | No | Custom filter update handler |
| children | RenderFunction | Yes | Render function receiving filter props |
Render Props
| Name | Type | Description |
|---|---|---|
| currentFilters | FilterInput[] | Current active filters |
| availableAttributes | FilterableAttribute[] | Filterable attributes |
| priceRange | PriceRange | Price min/max values |
| categories | CategoryFilter[] | Available categories |
| addFilter | (key, operation, value) => void | Add a filter |
| removeFilter | (key) => void | Remove filter by key |
| removeFilterValue | (key, value) => void | Remove specific value from filter |
| toggleFilter | (key, operation, value) => void | Toggle filter on/off |
| clearAllFilters | () => void | Clear all active filters |
| updateFilter | (filters) => void | Update all filters at once |
| hasFilter | (key) => boolean | Check if filter exists |
| getFilterValue | (key) => string | undefined | Get filter value by key |
| isOptionSelected | (attributeCode, optionId) => boolean | Check if option is selected |
| isCategorySelected | (categoryId) => boolean | Check if category is selected |
| getSelectedCount | (attributeCode) => number | Count selected options for attribute |
| getCategorySelectedCount | () => number | Count selected categories |
| isLoading | boolean | True when filters are updating |
| activeFilterCount | number | Number of active filters |
Type Definitions
FilterInput
interface FilterInput {
key: string; // Filter key (attribute code)
operation: 'eq' | 'in' | 'range' | 'gt' | 'lt'; // Filter operation
value: string; // Filter value(s)
}
FilterableAttribute
interface FilterableAttribute {
attributeCode: string;
attributeName: string;
attributeId: number;
options: Array<{
optionId: number;
optionText: string;
}>;
}
PriceRange
interface PriceRange {
min: number;
minText: string;
max: number;
maxText: string;
}
Examples
Attribute Checkboxes with Toggle
import { ProductFilter } from '@components/frontStore/catalog/ProductFilter';
function AttributeFilters({ currentFilters, availableAttributes, priceRange }) {
return (
<ProductFilter
currentFilters={currentFilters}
availableAttributes={availableAttributes}
priceRange={priceRange}
>
{({ addFilter, removeFilterValue, isOptionSelected, getSelectedCount }) => (
<div className="filters">
{availableAttributes.map(attr => (
<div key={attr.attributeCode} className="filter-group">
<h3>
{attr.attributeName}
{getSelectedCount(attr.attributeCode) > 0 && (
<span> ({getSelectedCount(attr.attributeCode)})</span>
)}
</h3>
{attr.options.map(option => (
<label key={option.optionId}>
<input
type="checkbox"
checked={isOptionSelected(attr.attributeCode, option.optionId.toString())}
onChange={() => {
if (isOptionSelected(attr.attributeCode, option.optionId.toString())) {
removeFilterValue(attr.attributeCode, option.optionId.toString());
} else {
addFilter(attr.attributeCode, 'in', option.optionId.toString());
}
}}
/>
{option.optionText}
</label>
))}
</div>
))}
</div>
)}
</ProductFilter>
);
}
Price Range Filter
import { ProductFilter } from '@components/frontStore/catalog/ProductFilter';
import { useState } from 'react';
function PriceFilter({ currentFilters, priceRange }) {
return (
<ProductFilter
currentFilters={currentFilters}
priceRange={priceRange}
>
{({ addFilter, removeFilter, hasFilter }) => {
const [min, setMin] = useState(priceRange.min);
const [max, setMax] = useState(priceRange.max);
return (
<div className="price-filter">
<h3>Price Range</h3>
<div>
<input
type="number"
value={min}
onChange={(e) => setMin(Number(e.target.value))}
min={priceRange.min}
max={priceRange.max}
/>
<span>to</span>
<input
type="number"
value={max}
onChange={(e) => setMax(Number(e.target.value))}
min={priceRange.min}
max={priceRange.max}
/>
</div>
<button onClick={() => addFilter('price', 'range', `${min}-${max}`)}>
Apply
</button>
{hasFilter('price') && (
<button onClick={() => removeFilter('price')}>Clear</button>
)}
</div>
);
}}
</ProductFilter>
);
}
Theme Override Example
A theme developer overrides CategoryProductsFilter to build a custom filter panel. Create this file in your theme:
themes/my-theme/src/pages/categoryView/CategoryProductsFilter.tsx
import { ProductFilter } from '@components/frontStore/catalog/ProductFilter';
function CategoryProductsFilter({
currentFilters,
availableAttributes,
categories,
priceRange
}) {
return (
<ProductFilter
currentFilters={currentFilters}
availableAttributes={availableAttributes}
categories={categories}
priceRange={priceRange}
>
{({
toggleFilter,
clearAllFilters,
isOptionSelected,
isCategorySelected,
activeFilterCount,
isLoading
}) => (
<aside className="my-theme-filters">
<div className="filter-header">
<h2>Filters</h2>
{activeFilterCount > 0 && (
<button onClick={clearAllFilters} disabled={isLoading}>
Clear All ({activeFilterCount})
</button>
)}
</div>
{categories.length > 0 && (
<div className="filter-section">
<h3>Categories</h3>
{categories.map(cat => (
<label key={cat.categoryId}>
<input
type="checkbox"
checked={isCategorySelected(cat.categoryId.toString())}
onChange={() => toggleFilter('cat', 'in', cat.categoryId.toString())}
disabled={isLoading}
/>
{cat.name}
</label>
))}
</div>
)}
{availableAttributes.map(attr => (
<div key={attr.attributeCode} className="filter-section">
<h3>{attr.attributeName}</h3>
{attr.options.map(option => (
<label key={option.optionId}>
<input
type="checkbox"
checked={isOptionSelected(attr.attributeCode, option.optionId.toString())}
onChange={() => toggleFilter(
attr.attributeCode, 'in', option.optionId.toString()
)}
disabled={isLoading}
/>
{option.optionText}
</label>
))}
</div>
))}
{isLoading && <div className="loading-overlay">Updating...</div>}
</aside>
)}
</ProductFilter>
);
}
export default CategoryProductsFilter;
export const layout = {
areaId: 'categoryPageLeft',
sortOrder: 10
};
Filter Operations
- eq: Equals (single value)
- in: In list (comma-separated values)
- range: Between values (min-max)
- gt: Greater than
- lt: Less than
Behavior
URL Management
Filters are synced with URL query parameters. Updates trigger:
- URL parameter update
- GraphQL page data fetch
- History state push
Multi-Value Filters
The in operation supports multiple values (comma-separated). Use addFilter to append values or removeFilterValue to remove specific values.
Reserved Keys
The following filter keys are reserved and excluded from active count: page, limit, ob, od.
Features
- Headless: Renders no UI — full control via render props
- URL Sync: Filters synced with URL parameters
- Multi-Value Support: Comma-separated values for 'in' operation
- Loading State: Shows loading during updates
- Active Filter Count: Tracks number of active filters
- Helper Functions: isOptionSelected, hasFilter, etc.
- Auto Page Reset: Clears page parameter when filtering
- Type Safe: Full TypeScript support
Related Components
- Pagination - Pagination component
- ProductList - Product listing component
Support us
EverShop is an open-source project that relies on community support. If you find our project useful, please consider sponsoring us.