Agent skill
studio-list-pattern
Provides patterns for implementing infinite scroll lists with sticky toolbars in Erify Studios. This skill should be used when building studio-scoped list pages with card-based layouts, debounced search, and cursor-based pagination.
Install this agent skill to your Project
npx add-skill https://github.com/majiayu000/claude-skill-registry/tree/main/skills/other/other/studio-list-pattern-allenlin90-eridu-services
SKILL.md
Studio List Pattern
This skill outlines the standard pattern for implementing infinite scroll lists in the erify_studios application. Unlike admin lists (which use server-side pagination with tables), studio lists use cursor-based pagination with card grids.
Canonical Examples
Study these real implementations as the source of truth:
- Task Templates List: index.tsx
- Feature Hook: use-task-templates.ts
- Toolbar Component: task-template-toolbar.tsx
Detailed code examples: See references/studio-list-examples.md
Pattern Overview
Studio lists combine several patterns:
- Infinite Scroll: Cursor-based pagination with Intersection Observer
- Sticky Toolbar: Search and actions remain accessible while scrolling
- Responsive Actions: Desktop buttons collapse to mobile dropdown
- Debounced Search: Local state with URL synchronization
- Card-Based Layout: Responsive grid instead of tables
Architecture
Route Component (index.tsx)
├─ useFeature() hook → Owns all query state
├─ Sticky Toolbar → Search + Actions
├─ ResponsiveCardGrid → Auto-fill grid layout
│ └─ Card components → Individual items
└─ useInfiniteScroll() sentinel → Triggers fetchNextPage
Implementation Steps
1. Create Feature Hook
Pattern: features/{feature}/hooks/use-{feature}.ts
The hook owns all query state and exposes both data and refetching states.
export function useFeature({ studioId }: UseFeatureProps): UseFeatureReturn {
const tableState = useTableUrlState({
from: '/studios/$studioId/feature',
searchColumnId: 'name',
defaultSorting: [{ id: 'updatedAt', desc: true }],
});
const { columnFilters } = tableState;
const searchQuery = (columnFilters.find((f) => f.id === 'name')?.value as string) || '';
const { data, fetchNextPage, hasNextPage, isFetchingNextPage, isLoading, isFetching, isError, refetch } =
useInfiniteQuery({
queryKey: ['feature', studioId, searchQuery],
queryFn: ({ pageParam }) => getItems(studioId, { limit: 20, name: searchQuery, cursor: pageParam }),
initialPageParam: undefined as string | undefined,
getNextPageParam: (lastPage) => lastPage.meta.nextCursor,
});
const items = useMemo(() => data?.pages.flatMap((page) => page.data) ?? [], [data]);
const total = data?.pages[0]?.meta.total ?? 0;
return { tableState, items, total, isLoading, isFetching, isError, isFetchingNextPage, hasNextPage, fetchNextPage, refetch };
}
Key Points:
- ✅ Use
useInfiniteQueryfor cursor-based pagination - ✅ Expose
isFetchingfor refresh button state (not justisLoading) - ✅ Flatten pages into single items array using
useMemo - ✅ Use
useTableUrlStatefor URL-synced search
2. Create Route Component with Sticky Toolbar
Pattern: routes/_authenticated/studios/$studioId/{feature}/index.tsx
export default function FeatureListRoute() {
const { studioId } = Route.useParams();
const { tableState, items, total, isLoading, isFetching, isError, isFetchingNextPage, hasNextPage, fetchNextPage, refetch } =
useFeature({ studioId });
const sentinelRef = useInfiniteScroll({ fetchNextPage, hasNextPage, isFetchingNextPage });
return (
<div className="flex flex-col h-full">
{/* Sticky Toolbar */}
<div className="sticky top-0 z-10 bg-background/80 backdrop-blur-sm border-b px-6 py-4">
<FeatureToolbar tableState={tableState} onRefresh={refetch} isRefreshing={isFetching} studioId={studioId} />
</div>
{/* Scrollable Content */}
<div className="flex-1 overflow-y-auto px-6 py-6">
{isLoading ? <LoadingState /> : isError ? <ErrorState /> : items.length === 0 ? <EmptyState /> : (
<>
<ResponsiveCardGrid>{items.map((item) => <ItemCard key={item.uid} item={item} />)}</ResponsiveCardGrid>
<div ref={sentinelRef} className="h-10" />
{isFetchingNextPage && <LoadingSpinner />}
</>
)}
</div>
</div>
);
}
Key Points:
- ✅ Sticky toolbar with
backdrop-blur-smfor glass effect - ✅ Separate scrollable content area
- ✅ Sentinel div at bottom for infinite scroll
- ✅ Handle all states: loading, error, empty, fetching next page
3. Create Toolbar with Responsive Actions
Pattern: features/{feature}/components/{feature}-toolbar.tsx
export function FeatureToolbar({ tableState, onRefresh, isRefreshing, studioId }: FeatureToolbarProps) {
const navigate = useNavigate();
const { columnFilters, onColumnFiltersChange } = tableState;
const searchValue = (columnFilters.find((f) => f.id === 'name')?.value as string) || '';
// Local state for immediate UI updates
const [localSearch, setLocalSearch] = useState(searchValue);
const debouncedSearch = useDebounce(localSearch, 300);
// Sync local state with URL state
useEffect(() => setLocalSearch(searchValue), [searchValue]);
// Update URL state when debounced value changes
useEffect(() => {
if (debouncedSearch !== searchValue) {
onColumnFiltersChange((old) => {
const newFilters = old.filter((f) => f.id !== 'name');
if (debouncedSearch) newFilters.push({ id: 'name', value: debouncedSearch });
return newFilters;
});
}
}, [debouncedSearch, searchValue, onColumnFiltersChange]);
return (
<div className="flex items-center gap-2 w-full">
<div className="relative flex-1">
<Search className="absolute left-2 top-2.5 h-4 w-4 text-muted-foreground" />
<Input placeholder="Search..." value={localSearch} onChange={(e) => setLocalSearch(e.target.value)} className="pl-8 h-9 w-full" />
</div>
{/* Desktop Actions */}
<div className="hidden md:flex items-center gap-2">
<Button variant="outline" size="sm" onClick={onRefresh} disabled={isRefreshing}>
<RotateCw className={`mr-2 h-4 w-4 ${isRefreshing ? 'animate-spin' : ''}`} />
Refresh
</Button>
<Button onClick={handleCreate} size="sm">Create New</Button>
</div>
{/* Mobile Actions Dropdown */}
<div className="md:hidden flex-none">
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="outline" size="icon" className="h-9 w-9"><MoreVertical className="h-4 w-4" /></Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem onClick={onRefresh}><RotateCw className="mr-2 h-4 w-4" />Refresh</DropdownMenuItem>
<DropdownMenuItem onClick={handleCreate}>Create New</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
</div>
);
}
Key Points:
- ✅ Debounced search (300ms) with local state for immediate UI updates
- ✅ Responsive actions: desktop buttons → mobile dropdown
- ✅ Refresh button uses
isFetchingstate (shows spinner during refetch)
4. Use Infinite Scroll Hook
export function useInfiniteScroll<T extends HTMLElement = HTMLDivElement>({
fetchNextPage,
hasNextPage,
isFetchingNextPage,
rootMargin = '400px',
enabled = true,
}: UseInfiniteScrollOptions) {
const sentinelRef = useRef<T>(null);
useEffect(() => {
const sentinel = sentinelRef.current;
if (!sentinel || !hasNextPage || isFetchingNextPage || !enabled) return;
const observer = new IntersectionObserver(
(entries) => { if (entries[0]?.isIntersecting) fetchNextPage(); },
{ rootMargin }
);
observer.observe(sentinel);
return () => observer.disconnect();
}, [hasNextPage, isFetchingNextPage, fetchNextPage, rootMargin, enabled]);
return sentinelRef;
}
How it works:
- Attach ref to sentinel element (empty div at bottom of list)
- When sentinel enters viewport (with 400px margin), trigger
fetchNextPage - Automatically cleanup on unmount
Query State Ownership Principle
[!IMPORTANT] Feature hooks own all query state. UI components receive callbacks, not query internals.
✅ GOOD:
// Hook owns query, exposes both data and refetching state
function useFeature({ studioId }) {
const query = useInfiniteQuery({ ... });
return {
items: ...,
isLoading: query.isLoading,
isFetching: query.isFetching, // ✅ Include for refresh button
refetch: query.refetch,
};
}
// Toolbar receives callback, doesn't know about React Query
<FeatureToolbar onRefresh={refetch} isRefreshing={isFetching} />
❌ BAD:
// Layout component using useIsFetching
function PageLayout({ refreshQueryKey }) {
const fetchingCount = useIsFetching({ queryKey: refreshQueryKey });
// This couples layout to query internals
}
Checklist
- Feature hook uses
useInfiniteQuerywith cursor pagination - Hook exposes
isFetchingfor refresh button state - Route component uses sticky toolbar pattern with backdrop blur
- Toolbar implements responsive actions (desktop buttons → mobile dropdown)
- Toolbar uses debounced search (300ms) with local state
- List component uses
ResponsiveCardGridfor layout - List component uses
useInfiniteScrollhook - All loading, error, and empty states are handled
- Query state ownership principle is followed
Related Skills
- admin-list-pattern - For admin section table-based lists
- frontend-ui-components - For UI component patterns
- frontend-state-management - For state management patterns
Recommended Agent Skills
Expand your agent's capabilities with these related and highly-rated skills.
agent-ops-spec
Manage specification documents in .agent/specs/. Use when user provides requirements, acceptance criteria, or feature descriptions that need to be tracked and validated against implementation.
agent-ops-state
Maintain .agent state files. Use at session start, after meaningful steps, and before concluding: read/update constitution/memory/focus/issues/baseline consistently.
agent-ops-spec
Manage specification documents in .agent/specs/. Use when user provides requirements, acceptance criteria, or feature descriptions that need to be tracked and validated against implementation.
agent-ops-testing
Test strategy, execution, and coverage analysis. Use when designing tests, running test suites, or analyzing test results beyond baseline checks.
agent-ops-testing
Test strategy, execution, and coverage analysis. Use when designing tests, running test suites, or analyzing test results beyond baseline checks.
agent-ops-state
Maintain .agent state files. Use at session start, after meaningful steps, and before concluding: read/update constitution/memory/focus/issues/baseline consistently.
Didn't find tool you were looking for?