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/components/frontStore/catalog/CategoryProductsFilter.tsx
There is no filter on the search page

The search page renders no product filter, so there is nothing to override there. To add one, drop a component into one of the areas the search page already exposes: searchPageTop / searchPageBottom (SearchPage.tsx), or searchProductsBefore / searchProductsAfter (components/frontStore/catalog/SearchProducts.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/components/frontStore/catalog/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;

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.