Faceted filter
A catalog sidebar: sixty-odd categories three levels deep, a box to find one without scrolling, and chips showing what is currently narrowing the results. The chosen facets belong in the URL so the search is shareable and survives a reload.
Why a tree here. Facets are naturally nested — picking Cycling should mean every subcategory under it — and a flat checkbox list forces the user to tick five boxes for what is one idea. The tri-state parent is also the summary: one glance says whether a department is fully or partly selected.
The demo
Type two characters to filter. Matches surface with their ancestors already unfolded; clearing the box gives you back the folders you had open.
history.replaceState(null, "", "/catalog") — 250 ms after you stop typing
The source
Trimmed to two departments so it fits on a screen; the demo above uses four. Everything else is identical.
"use client";
import { useEffect, useRef, useState } from "react";
import { Tree, type TreeDefinition, type TreeRef } from "react-virtual-checkbox-tree";
/** department → category → subcategories, flattened into the map below. */
const CATALOG: Record<string, Record<string, string[]>> = {
Electronics: {
Audio: ["Headphones", "Earbuds", "Speakers", "Microphones"],
Computers: ["Laptops", "Desktops", "Monitors", "Keyboards", "Mice"],
},
Outdoors: {
Camping: ["Tents", "Sleeping bags", "Stoves", "Lanterns"],
Cycling: ["Road bikes", "Gravel bikes", "Helmets", "Bike lights"],
},
};
const slug = (s: string) => s.toLowerCase().replace(/[^a-z0-9]+/g, "-");
function buildCatalog(): TreeDefinition {
const data: TreeDefinition = { __root__: { id: "__root__", label: "root", children: [] } };
const departments: string[] = [];
for (const [department, categories] of Object.entries(CATALOG)) {
const departmentId = slug(department);
const categoryIds: string[] = [];
for (const [category, leaves] of Object.entries(categories)) {
const categoryId = departmentId + "/" + slug(category);
const leafIds = leaves.map((leaf) => categoryId + "/" + slug(leaf));
leaves.forEach((leaf, i) => {
data[leafIds[i]] = { id: leafIds[i], label: leaf };
});
data[categoryId] = { id: categoryId, label: category, children: leafIds };
categoryIds.push(categoryId);
}
data[departmentId] = { id: departmentId, label: department, children: categoryIds };
departments.push(departmentId);
}
data.__root__.children = departments;
return data;
}
const catalog = buildCatalog();
const INITIAL_EXPANDED = ["electronics"];
const DEBOUNCE_MS = 250;
export function FacetedFilter() {
const [query, setQuery] = useState("");
const [facets, setFacets] = useState<string[]>([]);
const treeRef = useRef<TreeRef>(null);
// One router write per pause, not one per keystroke. Without this you push a
// history entry per character and re-run every server component with it.
useEffect(() => {
const timer = window.setTimeout(() => {
const params = new URLSearchParams();
if (query) params.set("q", query);
if (facets.length) params.set("f", [...facets].sort().join(","));
const qs = params.toString();
window.history.replaceState(null, "", qs ? "?" + qs : location.pathname);
}, DEBOUNCE_MS);
return () => window.clearTimeout(timer);
}, [facets, query]);
return (
<div>
<input
aria-label="Filter categories"
onChange={(event) => setQuery(event.target.value)}
placeholder="Filter facets…"
value={query}
/>
<Tree
aria-label="Product categories"
data={catalog}
estimateSize={30}
expandedItems={INITIAL_EXPANDED}
height={300}
// Two characters is enough for a 60-node taxonomy; the default is 3.
minSearchChars={2}
onCheck={setFacets}
ref={treeRef}
searchQuery={query}
/>
<div>
{[...facets].sort().map((id) => (
<button
key={id}
// Removing a chip goes straight to the engine. Round-tripping it
// through a controlled `checkedItems` prop is a render slower and,
// because the prop is applied in an effect, a frame stale.
onClick={() => treeRef.current?.getEngine().toggle(id, false)}
type="button"
>
{catalog[id]?.label ?? id} ×
</button>
))}
{facets.length > 0 && (
<button onClick={() => treeRef.current?.getEngine().uncheckAll()} type="button">
Clear all
</button>
)}
</div>
</div>
);
}Reading it back
The other half of the round trip: restoring the selection and the query from the URL on first load.
"use client";
import { useSearchParams } from "next/navigation";
import { useMemo, useState } from "react";
import { Tree } from "react-virtual-checkbox-tree";
// The same flat map `buildCatalog()` returns above, exported from its own file
// so both halves of the round trip share one tree.
import { catalog } from "./catalog";
/**
* Reading the URL back on load. `checkedItems` is applied whenever the array's
* identity changes, so it must be memoized — rebuilt on every render, it fights
* the user for control of the selection.
*/
export function FacetsFromUrl() {
const params = useSearchParams();
const [query, setQuery] = useState(params.get("q") ?? "");
const initialFacets = useMemo(
() => (params.get("f") ?? "").split(",").filter(Boolean),
[params]
);
return (
<Tree
aria-label="Product categories"
checkedItems={initialFacets}
data={catalog}
height={300}
minSearchChars={2}
onCheck={(ids) => console.log(ids)}
searchQuery={query}
/>
);
}Props exercised
searchQueryFilters to matches and their ancestors. Matching is diacritic- and case-insensitive, so resume finds Résumé.
minSearchCharsCharacters required before the query takes effect. Defaults to 3; this tree is small enough for 2.
searchScopeLeft at its default of "all", so a matching category carries its whole subtree into the filtered view — type "audio" and every product under it stays selectable. Pass "leaves" to match only leaf labels.
onCheckDrives the chips. Leaf IDs only, so a chip always corresponds to one real facet.
ref → getEngine()toggle(id, false) removes one facet and uncheckAll() clears them, without making the selection a controlled prop.
expandedItemsOpens the first department on load. While a search is active the engine manages expansion itself, and restores yours when the query clears.