Skip to main content

Advanced Filter Configuration

This page covers the internal state management, URL synchronization, scroll behavior, and hook architecture of the filter system in template/components/filters/. For the component API reference, see Filter UI Components.

State Management Architecture

useFilterState Hook

The core hook managing all filter state. It coordinates state updates, ref-based synchronization, URL updates, and loading indicators.

Parameters

ParameterTypeDefaultDescription
initialTagstring | nullundefinedPre-selected tag from route
initialCategorystring | nullundefinedPre-selected category from route
initialSortBystringundefinedInitial sort option

Return Value

The hook returns a memoized object with the following shape:

interface FilterStateReturn {
// State values
searchTerm: string;
selectedTags: TagId[];
selectedCategories: CategoryId[];
sortBy: SortOption;
selectedTag: TagId | null; // Single tag for navigation mode
selectedCategory: CategoryId | null; // Single category for navigation mode
isFiltersLoading: boolean;
locationFilter: LocationFilterState;

// Setters (with URL sync)
setSearchTerm: (term: string) => void;
setSelectedTags: (tags: TagId[] | ((prev: TagId[]) => TagId[])) => void;
setSelectedCategories: (cats: CategoryId[] | ((prev: CategoryId[]) => CategoryId[])) => void;
setSortBy: Dispatch<SetStateAction<SortOption>>;
setSelectedTag: Dispatch<SetStateAction<TagId | null>>;
setSelectedCategory: Dispatch<SetStateAction<CategoryId | null>>;

// Convenience actions
clearAllFilters: () => void;
removeSelectedTag: (tagId: TagId) => void;
addSelectedTag: (tagId: TagId) => void;
toggleSelectedTag: (tagId: TagId) => void;
removeSelectedCategory: (categoryId: CategoryId) => void;
addSelectedCategory: (categoryId: CategoryId) => void;
toggleSelectedCategory: (categoryId: CategoryId) => void;
clearSelectedCategories: () => void;

// Location actions
setNearMe: (coords: NearMeCoordinates | null) => void;
setLocationRadius: (radius: number) => void;
setLocationCity: (city: string | null) => void;
setLocationCountry: (country: string | null) => void;
clearLocationFilter: () => void;
}

Dual State Pattern

The hook maintains both React state and refs for critical values:

const [selectedTags, setSelectedTagsInternal] = useState<TagId[]>([]);
const selectedTagsRef = useRef<TagId[]>([]);

This pattern ensures that URL synchronization callbacks always access the latest values without stale closures, while state changes still trigger re-renders.

Category Toggle Behavior

Category toggling uses exclusive selection -- clicking an inactive category selects only that category, while clicking the active category deselects it (showing all):

const toggleSelectedCategory = useCallback((categoryId: CategoryId) => {
setSelectedCategories(prev =>
prev.includes(categoryId)
? [] // clicking active category -> deselect (show all)
: [categoryId] // clicking inactive category -> select ONLY this one
);
}, [setSelectedCategories]);

Loading Indicator

Filter changes trigger a brief loading state (400ms) to give visual feedback:

const syncFilterURL = useCallback((filterState: FilterState) => {
setIsFiltersLoading(true);
updateURL(filterState);
// Auto-scroll to filter section
loadingTimeoutRef.current = setTimeout(() => {
setIsFiltersLoading(false);
}, 400);
}, [updateURL]);

Auto-Scroll on Filter Change

When filters change and the user has scrolled past the first 100 pixels, the hook scrolls to either:

  1. An element with data-filter-scroll-target attribute (if present)
  2. The top of the page (fallback)
if (typeof window !== 'undefined' && window.scrollY > 100) {
const target = document.querySelector('[data-filter-scroll-target]');
if (target) {
target.scrollIntoView({ behavior: 'smooth', block: 'start' });
} else {
window.scrollTo({ top: 0, behavior: 'smooth' });
}
}

useFilterURLSync Hook

Synchronizes filter state with the browser URL using history.replaceState to avoid triggering Next.js server-side navigation.

Options

OptionTypeDefaultDescription
basePathstring'/'Base URL path
localestringundefinedCurrent locale from params
debounceMsnumber300Debounce delay for URL updates

URL Parameter Mapping

FilterURL ParameterFormat
TagstagsComma-separated IDs
CategoriescategoriesComma-separated IDs
SearchqText string
Near Me latitudenear_latNumber
Near Me longitudenear_lngNumber
RadiusradiusNumber (km)
CitycityString
CountrycountryString

URL Update Example

Filter state { tags: ['react', 'nextjs'], q: 'hooks' } produces:

/listing?tags=react,nextjs&q=hooks

Route Protection

The hook does not modify URLs on /categories/[slug] or /tags/[slug] routes, which have server-derived filter state from the route path:

const currentPath = window.location.pathname;
if (/\/(categories|tags)\/[^/]+/.test(currentPath)) return;

SSR Safety

The hook avoids useSearchParams() to prevent SSR hydration issues. Instead:

  • Initial state is passed via props (initialTag, initialCategory)
  • URL updates use window.history.replaceState directly
  • All window references are guarded with typeof window !== 'undefined'

Debouncing

URL updates are debounced (default 300ms) to avoid creating excessive browser history entries during rapid filter changes. Immediate updates bypass the debounce for cases like clearAllFilters.

Location Filter State

LocationFilterState Structure

interface LocationFilterState {
nearMe?: NearMeCoordinates;
city?: string;
country?: string;
sortByDistance?: boolean;
}

interface NearMeCoordinates {
latitude: number;
longitude: number;
radius: number; // km
}

Location Filter Mutual Exclusivity

Location filters are mutually exclusive -- setting one clears the others:

ActionClears
setNearMe(coords)city, country
setLocationCity(city)nearMe, country, sortByDistance
setLocationCountry(country)nearMe, city, sortByDistance
clearLocationFilter()All location state

Near Me Activation Flow

useStickyHeader Hook

Manages sticky behavior for the tag/filter bar based on scroll position.

Parameters

OptionTypeDefaultDescription
enableStickybooleantrueEnable/disable sticky behavior

Return Value

interface StickyHeaderReturn {
isSticky: boolean;
setIsSticky: Dispatch<SetStateAction<boolean>>;
}

Behavior

  • Uses useThrottledScroll (RAF-throttled) for performance
  • Transitions to sticky when scrollY > SCROLL_THRESHOLD (250px)
  • Transitions back when scrollY <= SCROLL_THRESHOLD
  • Uses a ref alongside state to prevent unnecessary re-renders during rapid scrolling
const handleScroll = useCallback(() => {
const scrollPosition = window.scrollY;
if (scrollPosition > SCROLL_THRESHOLD && !isStickyRef.current) {
isStickyRef.current = true;
setIsSticky(true);
} else if (scrollPosition <= SCROLL_THRESHOLD && isStickyRef.current) {
isStickyRef.current = false;
setIsSticky(false);
}
}, []);

useTagVisibility Hook

Manages the show/hide state for tag lists that exceed a maximum visible count.

Parameters

ParameterTypeDefaultDescription
tagsany[]requiredFull array of tags
maxVisibleTagsnumberFILTER_CONSTANTS.MAX_VISIBLE_TAGS (8)Maximum visible before collapse

Return Value

interface TagVisibilityReturn {
showAllTags: boolean;
visibleTags: any[]; // Sliced or full array
hasMoreTags: boolean; // tags.length > maxVisible
totalTags: number;
maxVisibleTags: number;
toggleTagVisibility: () => void;
setShowAllTags: Dispatch<SetStateAction<boolean>>;
}

Behavior

  • Initially shows only the first maxVisibleTags tags
  • toggleTagVisibility() switches between collapsed and expanded views
  • Uses useMemo for efficient tag slicing

Filter Constants Reference

const FILTER_CONSTANTS = {
MAX_VISIBLE_TAGS: 8, // Default max tags before "show more"
TEXT_TRUNCATE_LENGTH: 20, // Character limit for text truncation
SCROLL_THRESHOLD: 250, // Pixels before sticky header activates
STICKY_OFFSET: 4, // Top offset for sticky positioning (rem)
SCROLL_DURATION: 600, // Smooth scroll animation duration (ms)
TOOLTIP_DELAY: 300, // Delay before tooltip appears (ms)
TRANSITION_DURATION: 300, // CSS transition duration (ms)
MOBILE_BREAKPOINT: 'md', // Tailwind breakpoint for mobile
};

const SORT_OPTIONS = {
POPULARITY: 'popularity',
NAME_ASC: 'name-asc',
NAME_DESC: 'name-desc',
DATE_DESC: 'date-desc',
DATE_ASC: 'date-asc',
} as const;

const SORT_LABELS: Record<SortOption, string> = {
'popularity': 'Most Popular',
'name-asc': 'Name (A-Z)',
'name-desc': 'Name (Z-A)',
'date-desc': 'Newest First',
'date-asc': 'Oldest First',
};

Utility Functions

Style Utilities (utils/style-utils.ts)

Helpers for building dynamic CSS class strings for filter components.

Tag Utilities (utils/tag-utils.ts)

Functions for tag data manipulation, counting, and sorting.

Text Utilities (utils/text-utils.ts)

Text truncation and formatting helpers used by filter chips and labels.

Performance Optimizations

  1. Ref-based synchronization: Critical state values are mirrored in refs to avoid stale closure issues in callbacks
  2. Memoized return value: useFilterState wraps its return object in useMemo to prevent unnecessary re-renders
  3. Debounced URL updates: URL synchronization is debounced at 300ms to reduce history entries
  4. RAF-throttled scroll: Sticky header detection uses requestAnimationFrame throttling
  5. Computed tag slicing: useTagVisibility uses useMemo for efficient array slicing

Dependencies

  • next/navigation -- useParams for locale detection
  • @/lib/utils/url-filter-sync -- FilterState type definition
  • @/hooks/use-throttled-scroll -- RAF-throttled scroll listener