SearchParams-Based Catalog Filters
Обновлён 1 апр. 2026 г., 12:41 · 0 комментариев
SearchParams-Based Catalog Filters
For agentic workers: REQUIRED: Use superpowers:subagent-driven-development (if subagents available) or superpowers:executing-plans to implement this plan. Steps use checkbox (
- [ ]) syntax for tracking.
Goal: Replace Zustand-based filter state with React Context initialized from server searchParams — eliminates hydration mismatch and filter flicker.
Architecture: Server component parses URL searchParams and passes initial state as props to CatalogFilterProvider (React Context). All filter widgets read/write via useCatalogFilters() context hook. URL is synced via history.replaceState(). Zustand store is removed entirely.
Tech Stack: React Context, Next.js searchParams, React Query, history.replaceState()
File Structure
| File | Action | Responsibility |
|---|---|---|
shared/lib/catalog-filter-context.tsx | Create | Context provider + useCatalogFilters() hook |
shared/api/queries/use-catalog-items.ts | Modify | Read from context instead of Zustand |
app/page.tsx | Modify | Parse searchParams, pass to provider, remove store init |
views/home/ui/HomeView.tsx | Modify | Wrap children in CatalogFilterProvider |
widgets/home/ui/FilterSidebar.tsx | Modify | Use context instead of Zustand |
widgets/home/ui/SearchBar.tsx | Modify | Use context instead of Zustand |
widgets/home/ui/CategoryNav.tsx | Modify | Use context instead of Zustand |
widgets/home/ui/CourseFeed.tsx | Modify | Use context instead of Zustand |
widgets/home/ui/HeroBanner.tsx | Modify | Use context instead of Zustand |
widgets/home/ui/FilterDrawer.tsx | Modify | Use context instead of Zustand |
views/home/ui/CatalogOrchestrator.tsx | Delete | Replaced by provider's URL sync |
views/home/ui/StoreHydrator.tsx | Delete | No longer needed |
shared/store/catalog-filter.store.ts | Delete | Replaced by context |
shared/store/catalog-filter.core.ts | Delete | Replaced by context |
shared/store/catalog-filter.types.ts | Keep | Types and utilities still used |
shared/store/index.ts | Modify | Remove store re-exports |
Task 1: Create CatalogFilterProvider + useCatalogFilters hook
Files:
-
Create:
apps/web/src/shared/lib/catalog-filter-context.tsx -
Step 1: Create the context provider
'use client';
import { createContext, useCallback, useContext, useMemo, useRef, useState } from 'react';
import { DEFAULT_FILTERS, countActiveFilters, toggleArray, type FilterState } from '@/shared/store/catalog-filter.types';
export interface CatalogInitialState {
filters: FilterState;
search: string;
category: string | null;
subcategory: string | null;
location: string;
}
interface CatalogFilterContextValue {
filters: FilterState;
search: string;
selectedCategory: string | null;
selectedSubcategory: string | null;
selectedLocation: string;
drawerOpen: boolean;
page: number;
activeFilterCount: number;
updateFilters: (partial: Partial<FilterState>) => void;
clearFilters: () => void;
setSearch: (search: string) => void;
setSelectedCategory: (category: string | null) => void;
setSelectedSubcategory: (subcategory: string | null) => void;
setSelectedLocation: (location: string) => void;
setDrawerOpen: (open: boolean) => void;
setPage: (page: number | ((prev: number) => number)) => void;
}
const CatalogFilterContext = createContext<CatalogFilterContextValue | null>(null);
export function CatalogFilterProvider({
initialState,
children,
}: {
initialState: CatalogInitialState;
children: React.ReactNode;
}) {
const [filters, setFiltersState] = useState<FilterState>(initialState.filters);
const [search, setSearchState] = useState(initialState.search);
const [selectedCategory, setCategoryState] = useState<string | null>(initialState.category);
const [selectedSubcategory, setSubcategoryState] = useState<string | null>(initialState.subcategory);
const [selectedLocation, setLocationState] = useState(initialState.location);
const [drawerOpen, setDrawerOpen] = useState(false);
const [page, setPageState] = useState(0);
const isInitialRender = useRef(true);
// Sync state → URL (skip initial render to avoid overwriting server URL)
const syncUrl = useCallback((state: {
filters: FilterState; search: string;
category: string | null; subcategory: string | null; location: string;
}) => {
if (isInitialRender.current) { isInitialRender.current = false; return; }
const p = new URLSearchParams();
if (state.search) p.set('q', state.search);
if (state.category) p.set('category', state.category);
if (state.subcategory) p.set('subcategory', state.subcategory);
if (state.location) p.set('location', state.location);
state.filters.formats.forEach(v => p.append('format', v));
state.filters.languages.forEach(v => p.append('language', v));
state.filters.lessonTypes.forEach(v => p.append('lessonType', v));
state.filters.timeSlots.forEach(v => p.append('timeSlot', v));
if (state.filters.providerIndividual) p.set('providerIndividual', '1');
if (state.filters.providerCompany) p.set('providerCompany', '1');
if (state.filters.priceMin) p.set('priceMin', state.filters.priceMin);
if (state.filters.priceMax) p.set('priceMax', state.filters.priceMax);
if (state.filters.freeOnly) p.set('freeOnly', '1');
if (state.filters.durationMin) p.set('durationMin', state.filters.durationMin);
if (state.filters.durationMax) p.set('durationMax', state.filters.durationMax);
if (state.filters.ratingHigh) p.set('ratingHigh', '1');
if (state.filters.ageMode !== 'any') p.set('ageMode', state.filters.ageMode);
const qs = p.toString();
window.history.replaceState(null, '', qs ? `/?${qs}` : '/');
}, []);
const updateFilters = useCallback((partial: Partial<FilterState>) => {
setFiltersState(prev => {
const next = { ...prev, ...partial };
// Use setTimeout to avoid setState-during-render issues in syncUrl
return next;
});
setPageState(0);
}, []);
// We need a ref-based sync approach to avoid stale closures
const stateRef = useRef({ filters, search, category: selectedCategory, subcategory: selectedSubcategory, location: selectedLocation });
// Update URL whenever state changes — we'll use useEffect for this
// (moved to a useEffect inside the provider to avoid complexity)
const clearFilters = useCallback(() => {
setFiltersState(DEFAULT_FILTERS);
setPageState(0);
}, []);
const setSearch = useCallback((v: string) => {
setSearchState(v);
setPageState(0);
}, []);
const setSelectedCategory = useCallback((v: string | null) => {
setCategoryState(v);
setPageState(0);
}, []);
const setSelectedSubcategory = useCallback((v: string | null) => {
setSubcategoryState(v);
setPageState(0);
}, []);
const setSelectedLocation = useCallback((v: string | null) => {
setLocationState(v ?? '');
setPageState(0);
}, []);
const setPage = useCallback((p: number | ((prev: number) => number)) => {
setPageState(p);
}, []);
const activeFilterCount = useMemo(() => countActiveFilters(filters), [filters]);
// Sync to URL via useEffect
// We update stateRef first so syncUrl always reads latest
stateRef.current = { filters, search, category: selectedCategory, subcategory: selectedSubcategory, location: selectedLocation };
// This needs to be in a useEffect — we'll handle that in implementation
const value = useMemo<CatalogFilterContextValue>(() => ({
filters, search, selectedCategory, selectedSubcategory, selectedLocation,
drawerOpen, page, activeFilterCount,
updateFilters, clearFilters, setSearch, setSelectedCategory,
setSelectedSubcategory, setSelectedLocation, setDrawerOpen, setPage,
}), [filters, search, selectedCategory, selectedSubcategory, selectedLocation,
drawerOpen, page, activeFilterCount, updateFilters, clearFilters, setSearch,
setSelectedCategory, setSelectedSubcategory, setSelectedLocation, setPage]);
return (
<CatalogFilterContext.Provider value={value}>
{children}
</CatalogFilterContext.Provider>
);
}
export function useCatalogFilters() {
const ctx = useContext(CatalogFilterContext);
if (!ctx) throw new Error('useCatalogFilters must be used within CatalogFilterProvider');
return ctx;
}
- Step 2: Commit
Task 2: Wire up server → provider → components
Files:
-
Modify:
apps/web/src/app/page.tsx— remove store init, keep prefetch, pass initialState -
Modify:
apps/web/src/views/home/ui/HomeView.tsx— accept initialState, wrap in provider -
Step 1: Update page.tsx — remove
initializeStore,<script>, pass initialState prop -
Step 2: Update HomeView — wrap children in
<CatalogFilterProvider> -
Step 3: Commit
Task 3: Update useCatalogItems to read from context
Files:
-
Modify:
apps/web/src/shared/api/queries/use-catalog-items.ts -
Step 1: Replace all
useCatalogFilterStoreselectors withuseCatalogFilters()context -
Step 2: Commit
Task 4: Update all widget components
Files:
-
Modify:
apps/web/src/widgets/home/ui/FilterSidebar.tsx -
Modify:
apps/web/src/widgets/home/ui/SearchBar.tsx -
Modify:
apps/web/src/widgets/home/ui/CategoryNav.tsx -
Modify:
apps/web/src/widgets/home/ui/CourseFeed.tsx -
Modify:
apps/web/src/widgets/home/ui/HeroBanner.tsx -
Modify:
apps/web/src/widgets/home/ui/FilterDrawer.tsx -
Step 1: Update each component: replace
useCatalogFilterStorewithuseCatalogFilters() -
Step 2: Commit
Task 5: Clean up — remove Zustand store and dead code
Files:
-
Delete:
apps/web/src/views/home/ui/CatalogOrchestrator.tsx -
Delete:
apps/web/src/views/home/ui/StoreHydrator.tsx -
Delete:
apps/web/src/shared/store/catalog-filter.store.ts -
Delete:
apps/web/src/shared/store/catalog-filter.core.ts -
Modify:
apps/web/src/shared/store/index.ts— remove store re-exports -
Modify:
apps/web/src/views/home/ui/HomeView.tsx— remove CatalogOrchestrator -
Step 1: Delete files, update imports
-
Step 2: Commit
Task 6: Verify with Next.js MCP
- Step 1: Run
nextjs_call get_errorson port 5002 - Step 2: Test in browser with query params
/?format=ONLINE&lessonType=GROUP - Step 3: Verify: filters checked, courses filtered, no hydration mismatch, no flicker