Qadam Roadmap
проектplans/archived/2026-03-28-integrated/2026-03-18-searchparams-filters.md

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

FileActionResponsibility
shared/lib/catalog-filter-context.tsxCreateContext provider + useCatalogFilters() hook
shared/api/queries/use-catalog-items.tsModifyRead from context instead of Zustand
app/page.tsxModifyParse searchParams, pass to provider, remove store init
views/home/ui/HomeView.tsxModifyWrap children in CatalogFilterProvider
widgets/home/ui/FilterSidebar.tsxModifyUse context instead of Zustand
widgets/home/ui/SearchBar.tsxModifyUse context instead of Zustand
widgets/home/ui/CategoryNav.tsxModifyUse context instead of Zustand
widgets/home/ui/CourseFeed.tsxModifyUse context instead of Zustand
widgets/home/ui/HeroBanner.tsxModifyUse context instead of Zustand
widgets/home/ui/FilterDrawer.tsxModifyUse context instead of Zustand
views/home/ui/CatalogOrchestrator.tsxDeleteReplaced by provider's URL sync
views/home/ui/StoreHydrator.tsxDeleteNo longer needed
shared/store/catalog-filter.store.tsDeleteReplaced by context
shared/store/catalog-filter.core.tsDeleteReplaced by context
shared/store/catalog-filter.types.tsKeepTypes and utilities still used
shared/store/index.tsModifyRemove 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 useCatalogFilterStore selectors with useCatalogFilters() 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 useCatalogFilterStore with useCatalogFilters()

  • 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_errors on 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