Infinite Scroll Catalog Implementation Plan
Обновлён 1 апр. 2026 г., 12:41 · 0 комментариев
Infinite Scroll Catalog Implementation Plan
For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (
- [ ]) syntax for tracking.
Goal: Replace the "Load More" button with true infinite scroll that accumulates items as the user scrolls down.
Architecture: Switch useQuery → useInfiniteQuery (React Query accumulates pages automatically). An IntersectionObserver on a sentinel <div> at the bottom of the list triggers fetchNextPage(). The page state is removed from CatalogFilterContext since the infinite query manages pagination internally — filter changes automatically create a new query key, resetting the feed.
Tech Stack: React Query 5 useInfiniteQuery, IntersectionObserver API, Next.js App Router SSR with prefetchInfiniteQuery.
File Map
| File | Change |
|---|---|
apps/web/src/shared/api/keys/catalog.keys.ts | Add infiniteItems key |
apps/web/src/shared/api/queries/use-catalog-items.ts | Replace with useInfiniteQuery, rename export |
apps/web/src/shared/api/index.ts | Update export name |
apps/web/src/shared/lib/catalog-filter-context.tsx | Remove page / setPage |
apps/web/src/views/home/model/build-api-params.ts | Remove page and limit from output |
apps/web/src/views/home/model/prefetch-catalog.ts | Use prefetchInfiniteQuery |
apps/web/src/widgets/home/ui/CourseFeed.tsx | Sentinel div + IntersectionObserver, remove Load More |
Task 1: Add infiniteItems query key
Files:
-
Modify:
apps/web/src/shared/api/keys/catalog.keys.ts -
Add
infiniteItemsfactory tocatalogKeys:
export const catalogKeys = {
all: () => ['catalog'] as const,
items: (params?: Record<string, string | string[]>) =>
[...catalogKeys.all(), 'items', params] as const,
infiniteItems: (params?: Record<string, string | string[]>) =>
[...catalogKeys.all(), 'infinite-items', params] as const,
item: (slug: string) => [...catalogKeys.all(), 'item', slug] as const,
subjects: () => [...catalogKeys.all(), 'subjects'] as const,
subjectsByGroup: (groupId: string) =>
[...catalogKeys.all(), 'subjects', groupId] as const,
locations: () => [...catalogKeys.all(), 'locations'] as const,
};
- Commit:
git add apps/web/src/shared/api/keys/catalog.keys.ts
git commit -m "feat: add infiniteItems query key to catalogKeys"
Task 2: Replace useCatalogItems with useInfiniteCatalogItems
Files:
-
Modify:
apps/web/src/shared/api/queries/use-catalog-items.ts -
Replace the entire file content:
'use client';
import { useInfiniteQuery, keepPreviousData } from '@tanstack/react-query';
import { useCatalogFilters } from '@/shared/lib/catalog-filter-context';
import { catalogKeys } from '../keys/catalog.keys';
import { apiClient } from '../client';
import type { CatalogResponse } from '../api-types';
export function useInfiniteCatalogItems() {
const {
search,
selectedCategory,
selectedSubcategory,
selectedLocation,
filters,
} = useCatalogFilters();
const baseParams: Record<string, string | string[]> = {};
if (search) baseParams.search = search;
if (selectedSubcategory) baseParams.subjectId = selectedSubcategory;
else if (selectedCategory) baseParams.subjectGroupId = selectedCategory;
if (selectedLocation) baseParams.locationId = selectedLocation;
if (filters.format !== 'any') baseParams.studyFormat = [filters.format];
if (filters.lessonTypes.length > 0) baseParams.studyType = filters.lessonTypes;
if (filters.languages.length > 0) baseParams.language = filters.languages;
const sellerTypes: string[] = [];
if (filters.providerIndividual) sellerTypes.push('INDIVIDUAL_CONTRIBUTOR');
if (filters.providerCompany) sellerTypes.push('SCHOOL_OFFLINE', 'ONLINE_SCHOOL');
if (sellerTypes.length > 0) baseParams.sellerType = sellerTypes;
if (filters.freeOnly) {
baseParams.priceFrom = '0';
baseParams.priceTo = '0';
} else {
if (filters.priceMin) baseParams.priceFrom = filters.priceMin;
if (filters.priceMax) baseParams.priceTo = filters.priceMax;
}
if (filters.ageMode === 'adult') baseParams.ageFrom = '18';
else if (filters.ageMode === 'child') baseParams.ageTo = '17';
if (filters.durationMin) baseParams.durationFrom = filters.durationMin;
if (filters.durationMax) baseParams.durationTo = filters.durationMax;
return useInfiniteQuery({
queryKey: catalogKeys.infiniteItems(baseParams),
queryFn: ({ pageParam }) =>
apiClient
.get<CatalogResponse>('/catalog/items', {
params: { ...baseParams, page: String(pageParam), limit: '30' },
})
.then((r) => r.data),
initialPageParam: 1,
getNextPageParam: (lastPage) =>
lastPage.page < lastPage.totalPages ? lastPage.page + 1 : undefined,
placeholderData: keepPreviousData,
staleTime: 5 * 60 * 1000,
});
}
- Commit:
git add apps/web/src/shared/api/queries/use-catalog-items.ts
git commit -m "feat: replace useCatalogItems with useInfiniteCatalogItems"
Task 3: Update shared/api/index.ts export
Files:
-
Modify:
apps/web/src/shared/api/index.ts -
Replace the
useCatalogItemsexport withuseInfiniteCatalogItems:
Change line:
export { useCatalogItems } from './queries/use-catalog-items';
To:
export { useInfiniteCatalogItems } from './queries/use-catalog-items';
- Commit:
git add apps/web/src/shared/api/index.ts
git commit -m "feat: update shared api index to export useInfiniteCatalogItems"
Task 4: Remove page and setPage from CatalogFilterContext
⚠️ Tasks 4 and 7 are coupled —
CourseFeedcurrently destructuressetPagefrom the context. Apply Task 7 before or together with Task 4 to avoid a runtime destructuring error.
Files:
-
Modify:
apps/web/src/shared/lib/catalog-filter-context.tsx -
Remove
page: numberandsetPagefromCatalogFilterContextValueinterface -
Remove
const [page, setPageRaw] = useState(0)state declaration -
Remove
setPageuseCallback -
Remove all
setPageRaw(0)calls insideupdateFilters,clearFilters,setSearch,setSelectedCategory,setSelectedSubcategory,setSelectedLocation -
Remove
pageandsetPagefrom thevalueobject and its deps array
Result — the interface becomes:
interface CatalogFilterContextValue {
filters: FilterState;
search: string;
selectedCategory: string | null;
selectedSubcategory: string | null;
selectedLocation: string;
drawerOpen: boolean;
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;
}
- Commit:
git add apps/web/src/shared/lib/catalog-filter-context.tsx
git commit -m "refactor: remove page/setPage from CatalogFilterContext"
Task 5: Remove page and limit from build-api-params.ts, fix format array
Files:
-
Modify:
apps/web/src/views/home/model/build-api-params.ts -
Delete these two lines:
apiParams.page = '1';
apiParams.limit = '30';
The infinite query injects page and limit per-request in the queryFn.
- Fix
studyFormatto be an array (line 12), to match the infinite query's param structure:
Change:
if (filters.format !== 'any') apiParams.studyFormat = filters.format;
To:
if (filters.format !== 'any') apiParams.studyFormat = [filters.format];
This ensures SSR prefetch params are consistent with client-side infinite query params.
- Commit:
git add apps/web/src/views/home/model/build-api-params.ts
git commit -m "refactor: remove page/limit from buildApiParams (handled by infinite query)"
Task 6: Update SSR prefetch to prefetchInfiniteQuery
Files:
-
Modify:
apps/web/src/views/home/model/prefetch-catalog.ts -
Replace the entire file:
import { getQueryClient } from '@/shared/lib/get-query-client';
import { serverFetch } from '@/shared/api/server';
import { catalogKeys } from '@/shared/api';
import { buildApiParams, buildQueryString } from './build-api-params';
import type { CatalogInitialState } from '@/shared/lib/catalog-filter-context';
import type { CatalogResponse } from '@/shared/api';
export async function prefetchCatalog(state: CatalogInitialState) {
const qc = getQueryClient();
const apiParams = buildApiParams(state);
await Promise.all([
qc.prefetchInfiniteQuery({
queryKey: catalogKeys.infiniteItems(apiParams),
queryFn: ({ pageParam }) => {
const query = buildQueryString({ ...apiParams, page: String(pageParam), limit: '30' });
return serverFetch<CatalogResponse>(`/catalog/items${query}`, { revalidate: 300 });
},
initialPageParam: 1,
getNextPageParam: (lastPage: CatalogResponse) =>
lastPage.page < lastPage.totalPages ? lastPage.page + 1 : undefined,
pages: 1,
}),
qc.prefetchQuery({
queryKey: catalogKeys.subjects(),
queryFn: () => serverFetch('/catalog/subjects', { revalidate: 300 }),
}),
qc.prefetchQuery({
queryKey: catalogKeys.locations(),
queryFn: () => serverFetch('/catalog/locations', { revalidate: 300 }),
}),
]);
return qc;
}
- Commit:
git add apps/web/src/views/home/model/prefetch-catalog.ts
git commit -m "feat: use prefetchInfiniteQuery for catalog SSR prefetch"
Task 7: Update CourseFeed — IntersectionObserver + remove Load More
Files:
-
Modify:
apps/web/src/widgets/home/ui/CourseFeed.tsx -
Replace the entire file:
'use client';
import { useEffect, useRef } from 'react';
import { useTranslations } from 'next-intl';
import { ItemCard } from '@/entities/item';
import { useCatalogFilters } from '@/shared/lib/catalog-filter-context';
import { useInfiniteCatalogItems } from '@/shared/api';
import { CourseCardSkeleton } from './CourseCardSkeleton';
import { QadamMap } from './QadamMap';
export function CourseFeed() {
const t = useTranslations('catalog');
const { activeFilterCount, clearFilters } = useCatalogFilters();
const { data, isFetching, isFetchingNextPage, hasNextPage, fetchNextPage } = useInfiniteCatalogItems();
const items = data?.pages.flatMap((p) => p.items) ?? [];
const isInitialLoading = isFetching && items.length === 0;
const sentinelRef = useRef<HTMLDivElement>(null);
useEffect(() => {
const el = sentinelRef.current;
if (!el) return;
const observer = new IntersectionObserver(
(entries) => {
if (entries[0].isIntersecting && hasNextPage && !isFetchingNextPage) {
fetchNextPage();
}
},
{ rootMargin: '200px' },
);
observer.observe(el);
return () => observer.disconnect();
}, [hasNextPage, isFetchingNextPage, fetchNextPage]);
return (
<div className="flex-1 min-w-0">
<QadamMap items={items} />
{isInitialLoading ? (
<div className="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 gap-4">
{[1, 2, 3, 4, 5, 6].map((i) => (
<CourseCardSkeleton key={i} />
))}
</div>
) : items.length === 0 ? (
<div className="flex flex-col items-center py-16 text-center">
<div className="text-5xl mb-4">🔍</div>
<p className="text-gray-800 font-bold text-lg mb-1">{t('noResults')}</p>
<p className="text-gray-400 text-sm mb-6">{t('noResultsSub')}</p>
{activeFilterCount > 0 && (
<div className="bg-[#F9FAFB] border border-gray-200 rounded-2xl p-5 max-w-sm w-full">
<p className="text-sm font-semibold text-gray-800 mb-3">{t('narrowResults')}</p>
<div className="flex flex-wrap gap-2">
<button
onClick={clearFilters}
className="px-3 py-1.5 bg-white border border-gray-200 text-xs font-medium text-gray-700 rounded-lg hover:border-[#1DB57A] hover:text-[#1DB57A] transition-all"
>
{t('clearFilters')}
</button>
</div>
</div>
)}
</div>
) : (
<>
<div className="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 gap-4">
{items.map((item) => (
<ItemCard key={item.id} item={item} />
))}
</div>
{/* Sentinel triggers next page load */}
<div ref={sentinelRef} className="h-1" />
{isFetchingNextPage && (
<div className="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 gap-4 mt-4">
{[1, 2, 3].map((i) => (
<CourseCardSkeleton key={i} />
))}
</div>
)}
</>
)}
</div>
);
}
- Commit:
git add apps/web/src/widgets/home/ui/CourseFeed.tsx
git commit -m "feat: infinite scroll with IntersectionObserver, remove Load More button"