Skip to main content

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 children function renders.
  • Theme developers do not override ProductFilter. Instead, they override the parent components that consume it (CategoryProductsFilter and 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 ComponentRouteOverride Path in Theme
CategoryProductsFiltercategoryViewthemes/<name>/src/pages/categoryView/CategoryProductsFilter.tsx
SearchProductsFiltercatalogSearchthemes/<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

NameTypeRequiredDescription
currentFiltersFilterInput[]YesActive filters array
availableAttributesFilterableAttribute[]NoFilterable product attributes
priceRangePriceRangeYesMin/max price range
categoriesCategoryFilter[]NoAvailable categories
settingobjectNoStore settings (language, currency)
onFilterUpdate(filters: FilterInput[]) => voidNoCustom filter update handler
childrenRenderFunctionYesRender function receiving filter props

Render Props

NameTypeDescription
currentFiltersFilterInput[]Current active filters
availableAttributesFilterableAttribute[]Filterable attributes
priceRangePriceRangePrice min/max values
categoriesCategoryFilter[]Available categories
addFilter(key, operation, value) => voidAdd a filter
removeFilter(key) => voidRemove filter by key
removeFilterValue(key, value) => voidRemove specific value from filter
toggleFilter(key, operation, value) => voidToggle filter on/off
clearAllFilters() => voidClear all active filters
updateFilter(filters) => voidUpdate all filters at once
hasFilter(key) => booleanCheck if filter exists
getFilterValue(key) => string | undefinedGet filter value by key
isOptionSelected(attributeCode, optionId) => booleanCheck if option is selected
isCategorySelected(categoryId) => booleanCheck if category is selected
getSelectedCount(attributeCode) => numberCount selected options for attribute
getCategorySelectedCount() => numberCount selected categories
isLoadingbooleanTrue when filters are updating
activeFilterCountnumberNumber 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:

  1. URL parameter update
  2. GraphQL page data fetch
  3. 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


Support us


EverShop is an open-source project that relies on community support. If you find our project useful, please consider sponsoring us.