references/component-templates.md
# Component Templates
Annotated templates for common React component patterns. Copy and adapt these templates as starting points.
---
## 1. Page Component
Route-level component that fetches data and composes features.
```tsx
import { useQuery } from "@tanstack/react-query";
import { useParams } from "react-router-dom";
import { userQueries } from "@/api/users";
import { UserProfile } from "@/features/users/UserProfile";
import { Spinner } from "@/components/Spinner";
import { ErrorMessage } from "@/components/ErrorMessage";
/**
* Page component: default export, data fetching at this level.
* Route: /users/:userId
*/
export default function UserDetailPage() {
const { userId } = useParams<{ userId: string }>();
const numericId = Number(userId);
const { data: user, isPending, isError, error } = useQuery(
userQueries.detail(numericId)
);
if (isPending) {
return (
<main>
<Spinner aria-label="Loading user profile" />
</main>
);
}
if (isError) {
return (
<main>
<ErrorMessage error={error} />
</main>
);
}
return (
<main>
<h1>{user.displayName}'s Profile</h1>
<UserProfile user={user} />
</main>
);
}
```
**Key patterns:**
- Default export for page components (enables lazy loading)
- Data fetching via TanStack Query at the page level
- Handle all three states: pending, error, success
- Semantic `<main>` wrapper
- Page title as `<h1>`
---
## 2. Form Component
Reusable form with validation, error display, and submission handling.
```tsx
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { z } from "zod";
import { Input } from "@/components/Form/Input";
// 1. Define schema — single source of truth for validation
const createUserSchema = z.object({
email: z.string().email("Please enter a valid email address"),
displayName: z
.string()
.min(1, "Name is required")
.max(100, "Name must be 100 characters or fewer"),
role: z.enum(["admin", "editor", "member"], {
errorMap: () => ({ message: "Please select a valid role" }),
}),
});
type CreateUserFormData = z.infer<typeof createUserSchema>;
// 2. Define props — callback for parent to handle submission
interface CreateUserFormProps {
onSubmit: (data: CreateUserFormData) => Promise<void>;
defaultValues?: Partial<CreateUserFormData>;
}
// 3. Form component — named export (reusable)
export function CreateUserForm({ onSubmit, defaultValues }: CreateUserFormProps) {
const {
register,
handleSubmit,
formState: { errors, isSubmitting },
reset,
} = useForm<CreateUserFormData>({
resolver: zodResolver(createUserSchema),
defaultValues: {
role: "member",
...defaultValues,
},
});
const handleFormSubmit = async (data: CreateUserFormData) => {
await onSubmit(data);
reset();
};
return (
<form onSubmit={handleSubmit(handleFormSubmit)} noValidate>
{/* Email field */}
<div>
<label htmlFor="email">Email</label>
<input
id="email"
type="email"
{...register("email")}
aria-invalid={!!errors.email}
aria-describedby={errors.email ? "email-error" : undefined}
/>
{errors.email && (
<span id="email-error" role="alert">
{errors.email.message}
</span>
)}
</div>
{/* Display name field */}
<div>
<label htmlFor="displayName">Display Name</label>
<input
id="displayName"
type="text"
{...register("displayName")}
aria-invalid={!!errors.displayName}
aria-describedby={errors.displayName ? "name-error" : undefined}
/>
{errors.displayName && (
<span id="name-error" role="alert">
{errors.displayName.message}
</span>
)}
</div>
{/* Role select */}
<div>
<label htmlFor="role">Role</label>
<select
id="role"
{...register("role")}
aria-invalid={!!errors.role}
>
<option value="member">Member</option>
<option value="editor">Editor</option>
<option value="admin">Admin</option>
</select>
{errors.role && (
<span role="alert">{errors.role.message}</span>
)}
</div>
{/* Submit */}
<button type="submit" disabled={isSubmitting}>
{isSubmitting ? "Saving..." : "Create User"}
</button>
</form>
);
}
```
**Key patterns:**
- Zod schema as single source of truth for validation
- `react-hook-form` with `zodResolver` for form state
- Every input has a `<label>` with matching `htmlFor`/`id`
- `aria-invalid` on inputs with errors
- `aria-describedby` linking to error messages
- Error messages wrapped in `role="alert"`
- Submit button disabled during submission
---
## 3. List with Pagination
Data list with cursor-based pagination and empty state.
```tsx
import { useQuery } from "@tanstack/react-query";
import { useState } from "react";
import { postQueries } from "@/api/posts";
import { Spinner } from "@/components/Spinner";
interface PostListProps {
userId: number;
}
export function PostList({ userId }: PostListProps) {
const [cursor, setCursor] = useState<string | null>(null);
const { data, isPending, isError } = useQuery(
postQueries.byUser(userId, { cursor })
);
if (isPending) return <Spinner aria-label="Loading posts" />;
if (isError) return <p role="alert">Failed to load posts.</p>;
if (data.items.length === 0 && !cursor) {
return (
<section aria-label="Posts">
<p>No posts yet. Create your first post!</p>
</section>
);
}
return (
<section aria-label="Posts">
<ul>
{data.items.map((post) => (
<li key={post.id}>
<article>
<h3>{post.title}</h3>
<p>{post.excerpt}</p>
<time dateTime={post.createdAt}>
{new Date(post.createdAt).toLocaleDateString()}
</time>
</article>
</li>
))}
</ul>
{data.hasMore && (
<button
type="button"
onClick={() => setCursor(data.nextCursor)}
>
Load more posts
</button>
)}
</section>
);
}
```
**Key patterns:**
- Cursor-based pagination matching the API contract
- Empty state for no results
- Semantic list (`<ul>` + `<li>`) with `<article>` for each item
- `<time>` element with `dateTime` attribute
- `aria-label` on the section for screen reader context
---
## 4. Modal Dialog
Accessible modal with focus trapping and keyboard handling.
```tsx
import { useEffect, useRef } from "react";
import { createPortal } from "react-dom";
interface ModalProps {
isOpen: boolean;
onClose: () => void;
title: string;
children: React.ReactNode;
}
export function Modal({ isOpen, onClose, title, children }: ModalProps) {
const dialogRef = useRef<HTMLDialogElement>(null);
const previousFocusRef = useRef<HTMLElement | null>(null);
useEffect(() => {
const dialog = dialogRef.current;
if (!dialog) return;
if (isOpen) {
previousFocusRef.current = document.activeElement as HTMLElement;
dialog.showModal();
} else {
dialog.close();
previousFocusRef.current?.focus();
}
}, [isOpen]);
// Close on Escape (native <dialog> handles this)
// Close on backdrop click
const handleBackdropClick = (e: React.MouseEvent) => {
if (e.target === dialogRef.current) {
onClose();
}
};
if (!isOpen) return null;
return createPortal(
<dialog
ref={dialogRef}
onClick={handleBackdropClick}
aria-labelledby="modal-title"
>
<div className="modal-content" role="document">
<header>
<h2 id="modal-title">{title}</h2>
<button
type="button"
onClick={onClose}
aria-label="Close dialog"
>
×
</button>
</header>
<div className="modal-body">
{children}
</div>
</div>
</dialog>,
document.body,
);
}
```
**Key patterns:**
- Native `<dialog>` element for built-in accessibility
- `showModal()` for modal behavior (focus trapping, backdrop)
- `aria-labelledby` pointing to the title
- Restore focus to previous element on close
- Portal rendering to avoid z-index issues
- Backdrop click to close
- Close button with `aria-label`
references/tanstack-query-patterns.md
# TanStack Query Patterns
CRUD operation patterns, query key conventions, cache invalidation, and optimistic updates for TanStack Query v5 with React.
---
## Query Key Conventions
Structure query keys hierarchically for targeted invalidation:
```tsx
// All users (list)
["users"]
// Single user
["users", userId]
// Filtered user list
["users", { q: "search", role: "admin", page: 1 }]
// User's posts (nested resource)
["users", userId, "posts"]
// Single post
["posts", postId]
```
**Rules:**
- First element is the resource name (string)
- Subsequent elements narrow the scope (ids, filters)
- Object filters should be normalized (same key order)
- Use `queryOptions()` factory to prevent key duplication
---
## Query Options Factory
Centralize all query definitions per resource:
```tsx
// api/users.ts
import { queryOptions } from "@tanstack/react-query";
import { apiClient } from "./client";
import type { User, UserListResponse } from "@/types/user";
interface UserListParams {
q?: string;
cursor?: string | null;
limit?: number;
}
export const userQueries = {
all: () =>
queryOptions({
queryKey: ["users"] as const,
queryFn: () => apiClient.get<UserListResponse>("/users"),
staleTime: 5 * 60 * 1000, // 5 minutes
}),
list: (params: UserListParams) =>
queryOptions({
queryKey: ["users", params] as const,
queryFn: () =>
apiClient.get<UserListResponse>("/users", { params }),
staleTime: 2 * 60 * 1000,
}),
detail: (userId: number) =>
queryOptions({
queryKey: ["users", userId] as const,
queryFn: () => apiClient.get<User>(`/users/${userId}`),
staleTime: 5 * 60 * 1000,
}),
};
```
**Usage in components:**
```tsx
// Clean and type-safe — no key duplication
const { data } = useQuery(userQueries.detail(42));
const { data } = useQuery(userQueries.list({ q: search }));
```
---
## CRUD Operations
### Create (useMutation + invalidate)
```tsx
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { apiClient } from "@/api/client";
import type { UserCreate, User } from "@/types/user";
export function useCreateUser() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (data: UserCreate) =>
apiClient.post<User>("/users", data),
onSuccess: () => {
// Invalidate all user lists — they're now stale
queryClient.invalidateQueries({ queryKey: ["users"] });
},
});
}
// Usage in component
function CreateUserButton() {
const createUser = useCreateUser();
const handleClick = () => {
createUser.mutate(
{ email: "new@example.com", displayName: "New User" },
{
onSuccess: (user) => {
toast.success(`Created ${user.displayName}`);
},
onError: (error) => {
toast.error(error.message);
},
},
);
};
return (
<button onClick={handleClick} disabled={createUser.isPending}>
{createUser.isPending ? "Creating..." : "Create User"}
</button>
);
}
```
### Update (useMutation + invalidate specific)
```tsx
export function useUpdateUser(userId: number) {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (data: UserPatch) =>
apiClient.patch<User>(`/users/${userId}`, data),
onSuccess: (updatedUser) => {
// Update the specific user in cache
queryClient.setQueryData(["users", userId], updatedUser);
// Invalidate lists (they may be sorted/filtered differently)
queryClient.invalidateQueries({ queryKey: ["users"], exact: false });
},
});
}
```
### Delete (useMutation + remove from cache)
```tsx
export function useDeleteUser() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (userId: number) =>
apiClient.delete(`/users/${userId}`),
onSuccess: (_, userId) => {
// Remove from cache
queryClient.removeQueries({ queryKey: ["users", userId] });
// Invalidate lists
queryClient.invalidateQueries({ queryKey: ["users"] });
},
});
}
```
---
## Optimistic Updates
Update the UI immediately, rollback on error:
```tsx
export function useToggleFavorite(postId: number) {
const queryClient = useQueryClient();
return useMutation({
mutationFn: () => apiClient.post(`/posts/${postId}/favorite`),
onMutate: async () => {
// Cancel outgoing queries to prevent race conditions
await queryClient.cancelQueries({ queryKey: ["posts", postId] });
// Snapshot current state for rollback
const previousPost = queryClient.getQueryData<Post>(["posts", postId]);
// Optimistically update
queryClient.setQueryData<Post>(["posts", postId], (old) =>
old ? { ...old, isFavorited: !old.isFavorited } : old,
);
return { previousPost };
},
onError: (_err, _vars, context) => {
// Rollback on error
if (context?.previousPost) {
queryClient.setQueryData(["posts", postId], context.previousPost);
}
},
onSettled: () => {
// Always refetch to sync with server
queryClient.invalidateQueries({ queryKey: ["posts", postId] });
},
});
}
```
---
## Infinite Scroll
```tsx
import { useInfiniteQuery } from "@tanstack/react-query";
export function useInfinitePosts() {
return useInfiniteQuery({
queryKey: ["posts", "infinite"],
queryFn: ({ pageParam }) =>
apiClient.get<PostListResponse>("/posts", {
params: { cursor: pageParam, limit: 20 },
}),
initialPageParam: null as string | null,
getNextPageParam: (lastPage) =>
lastPage.hasMore ? lastPage.nextCursor : undefined,
staleTime: 2 * 60 * 1000,
});
}
// Usage
function PostFeed() {
const { data, fetchNextPage, hasNextPage, isFetchingNextPage } =
useInfinitePosts();
const allPosts = data?.pages.flatMap((page) => page.items) ?? [];
return (
<div>
{allPosts.map((post) => (
<PostCard key={post.id} post={post} />
))}
{hasNextPage && (
<button onClick={() => fetchNextPage()} disabled={isFetchingNextPage}>
{isFetchingNextPage ? "Loading..." : "Load More"}
</button>
)}
</div>
);
}
```
---
## Dependent Queries
Query that depends on another query's result:
```tsx
function UserPosts({ userId }: { userId: number }) {
// First query: get user
const { data: user } = useQuery(userQueries.detail(userId));
// Second query: depends on user data
const { data: posts } = useQuery({
queryKey: ["users", userId, "posts", user?.preferredCategory],
queryFn: () =>
apiClient.get(`/users/${userId}/posts`, {
params: { category: user!.preferredCategory },
}),
enabled: !!user, // Only run when user data is available
});
// ...
}
```
---
## Global Configuration
```tsx
// main.tsx
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { ReactQueryDevtools } from "@tanstack/react-query-devtools";
const queryClient = new QueryClient({
defaultOptions: {
queries: {
staleTime: 5 * 60 * 1000, // 5 minutes (avoid staleTime: 0)
gcTime: 10 * 60 * 1000, // 10 minutes garbage collection
retry: 1, // Retry once on failure
refetchOnWindowFocus: false, // Disable aggressive refetching
},
mutations: {
retry: 0, // No retry for mutations
},
},
});
function App() {
return (
<QueryClientProvider client={queryClient}>
<Router />
<ReactQueryDevtools initialIsOpen={false} />
</QueryClientProvider>
);
}
```
SKILL.md
---
name: react-frontend-expert
description: >-
React/TypeScript frontend implementation patterns. Use during the implementation phase
when creating or modifying React components, custom hooks, pages, data fetching logic
with TanStack Query, forms, or routing. Covers component structure, hooks rules, custom
hook design (useAuth, useDebounce, usePagination), TypeScript strict-mode conventions,
form handling, accessibility requirements, and project structure. Does NOT cover testing
(use react-testing-patterns), E2E testing (use e2e-testing), or deployment.
license: MIT
compatibility: 'React 18+, TypeScript 5+, TanStack Query 5+, Vite 5+, React Router 6+'
metadata:
author: platform-team
version: '1.0.0'
sdlc-phase: implementation
allowed-tools: Read Edit Write Bash(npm:*) Bash(npx:*)
context: fork
---
# React Frontend Expert
## When to Use
Activate this skill when:
- Creating or modifying React components (functional components only)
- Writing custom hooks (`useXxx`)
- Building pages with routing
- Implementing data fetching with TanStack Query
- Handling forms with validation
- Setting up project structure for a React/TypeScript application
Do NOT use this skill for:
- Writing component or hook tests (use `react-testing-patterns`)
- E2E browser testing (use `e2e-testing`)
- API contract design (use `api-design-patterns`)
- Backend implementation (use `python-backend-expert`)
- Deployment or CI/CD (use `deployment-pipeline`)
## Instructions
### Project Structure
```
src/
├── api/ # API client functions and query options
│ ├── client.ts # Axios/fetch instance with interceptors
│ ├── users.ts # User API functions + query options
│ └── posts.ts
├── components/ # Shared, reusable UI components
│ ├── Button.tsx
│ ├── Modal.tsx
│ ├── Table/
│ │ ├── Table.tsx
│ │ └── TablePagination.tsx
│ └── Form/
│ ├── Input.tsx
│ └── Select.tsx
├── features/ # Domain-specific feature components
│ ├── users/
│ │ ├── UserList.tsx
│ │ └── UserProfile.tsx
│ └── posts/
│ └── PostEditor.tsx
├── hooks/ # Custom hooks
│ ├── useAuth.ts
│ ├── useDebounce.ts
│ └── usePagination.ts
├── layouts/ # Layout components
│ ├── MainLayout.tsx
│ └── AuthLayout.tsx
├── pages/ # Route-level page components
│ ├── HomePage.tsx
│ ├── LoginPage.tsx
│ └── users/
│ ├── UserListPage.tsx
│ └── UserDetailPage.tsx
├── types/ # Shared TypeScript types
│ ├── api.ts # API response types
│ └── user.ts
├── App.tsx # Root component with providers and router
└── main.tsx # Entry point
```
### Component Structure
#### Functional Components Only
```tsx
interface UserCardProps {
user: User;
onEdit: (userId: number) => void;
showEmail?: boolean;
}
export function UserCard({ user, onEdit, showEmail = false }: UserCardProps) {
return (
<article className="user-card">
<h3>{user.displayName}</h3>
{showEmail && <p>{user.email}</p>}
<button type="button" onClick={() => onEdit(user.id)}>
Edit
</button>
</article>
);
}
```
**Component rules:**
- Named exports for shared components: `export function Button`
- Default exports for page components: `export default function UserListPage`
- Props interface named `{Component}Props`
- Destructure props in function signature
- Keep components under 200 lines — extract sub-components or hooks when larger
- Use `children` and composition over deep prop drilling
- Never use `React.FC` — use plain function syntax
#### Component File Organization
For complex components, co-locate related files:
```
UserProfile/
├── UserProfile.tsx # Main component
├── UserProfile.css # Styles (or .module.css)
├── UserAvatar.tsx # Sub-component
└── index.ts # Re-export: export { UserProfile } from './UserProfile'
```
### Hooks Rules and Custom Hooks
#### Rules of Hooks
1. Only call hooks at the top level — never inside loops, conditions, or nested functions
2. Only call hooks from React function components or custom hooks
3. Custom hooks must start with `use`
#### Custom Hook Patterns
**useDebounce:**
```tsx
export function useDebounce<T>(value: T, delayMs: number): T {
const [debouncedValue, setDebouncedValue] = useState(value);
useEffect(() => {
const timer = setTimeout(() => setDebouncedValue(value), delayMs);
return () => clearTimeout(timer);
}, [value, delayMs]);
return debouncedValue;
}
```
**useAuth:**
```tsx
interface AuthContext {
user: User | null;
isAuthenticated: boolean;
login: (credentials: LoginCredentials) => Promise<void>;
logout: () => void;
}
const AuthContext = createContext<AuthContext | null>(null);
export function useAuth(): AuthContext {
const context = useContext(AuthContext);
if (!context) {
throw new Error("useAuth must be used within AuthProvider");
}
return context;
}
```
**usePagination:**
```tsx
interface PaginationState {
cursor: string | null;
hasMore: boolean;
goToNext: (nextCursor: string) => void;
reset: () => void;
}
export function usePagination(): PaginationState {
const [cursor, setCursor] = useState<string | null>(null);
const [hasMore, setHasMore] = useState(true);
return {
cursor,
hasMore,
goToNext: (nextCursor: string) => {
setCursor(nextCursor);
},
reset: () => {
setCursor(null);
setHasMore(true);
},
};
}
```
**When to extract a custom hook:**
- Logic is reused across 2+ components
- Component has complex state management (>3 `useState` calls)
- Side effects need encapsulation (subscriptions, timers)
- Data fetching logic can be shared
### Data Fetching with TanStack Query
#### Query Options Factory (Recommended)
Centralize query key and function definitions to prevent key collisions:
```tsx
// api/users.ts
import { queryOptions } from "@tanstack/react-query";
export const userQueries = {
all: () =>
queryOptions({
queryKey: ["users"],
queryFn: () => apiClient.get<UserListResponse>("/users"),
}),
detail: (userId: number) =>
queryOptions({
queryKey: ["users", userId],
queryFn: () => apiClient.get<UserResponse>(`/users/${userId}`),
}),
search: (query: string) =>
queryOptions({
queryKey: ["users", "search", query],
queryFn: () => apiClient.get<UserListResponse>(`/users?q=${query}`),
enabled: query.length > 0,
}),
};
```
#### Using Queries in Components
```tsx
export function UserDetailPage({ userId }: { userId: number }) {
const { data: user, isPending, isError, error } = useQuery(
userQueries.detail(userId)
);
if (isPending) return <Spinner />;
if (isError) return <ErrorMessage error={error} />;
return <UserProfile user={user} />;
}
```
#### Mutations with Cache Invalidation
```tsx
export function useCreateUser() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (data: UserCreate) =>
apiClient.post<UserResponse>("/users", data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["users"] });
},
});
}
```
**TanStack Query rules:**
- Set `staleTime` > 0 (default 0 is too aggressive): `staleTime: 5 * 60 * 1000` (5 min)
- Use `invalidateQueries()` after mutations — never manual `refetch()`
- Handle all states: `isPending`, `isError`, `data`
- Use `queryOptions()` factory — prevents key typos and duplication
- Use `enabled` to prevent queries from running with incomplete parameters
### TypeScript Conventions
```tsx
// Use `interface` for object shapes (components props, API responses)
interface User {
id: number;
email: string;
displayName: string;
role: "admin" | "editor" | "member";
}
// Use `type` for unions, intersections, and computed types
type UserRole = User["role"];
type CreateOrUpdate = UserCreate | UserUpdate;
// Discriminated unions for state machines
type AsyncState<T> =
| { status: "idle" }
| { status: "loading" }
| { status: "success"; data: T }
| { status: "error"; error: Error };
```
**TypeScript rules:**
- Enable `strict: true` in `tsconfig.json` — no exceptions
- Never use `any` — use `unknown` for truly unknown types
- Use `as const` for literal object types
- Prefer `interface` for extensible types, `type` for everything else
- Use generics for reusable utility types and hooks
- Export types from `types/` directory for shared use
### Form Handling
```tsx
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { z } from "zod";
const userSchema = z.object({
email: z.string().email("Invalid email"),
displayName: z.string().min(1, "Required").max(100),
role: z.enum(["admin", "editor", "member"]),
});
type UserFormData = z.infer<typeof userSchema>;
export function UserForm({ onSubmit }: { onSubmit: (data: UserFormData) => void }) {
const {
register,
handleSubmit,
formState: { errors, isSubmitting },
} = useForm<UserFormData>({
resolver: zodResolver(userSchema),
});
return (
<form onSubmit={handleSubmit(onSubmit)} noValidate>
<label htmlFor="email">Email</label>
<input id="email" type="email" {...register("email")} aria-invalid={!!errors.email} />
{errors.email && <span role="alert">{errors.email.message}</span>}
<label htmlFor="displayName">Name</label>
<input id="displayName" {...register("displayName")} aria-invalid={!!errors.displayName} />
{errors.displayName && <span role="alert">{errors.displayName.message}</span>}
<button type="submit" disabled={isSubmitting}>Save</button>
</form>
);
}
```
### Accessibility Requirements
Every component must meet WCAG 2.1 AA:
1. **Semantic HTML first:** Use `<button>`, `<nav>`, `<main>`, `<article>` — not `<div onClick>`
2. **Labels:** Every form input has a `<label>` with matching `htmlFor`/`id`
3. **ARIA only when needed:** `aria-label` for icon-only buttons, `aria-live` for dynamic updates, `role="alert"` for errors
4. **Keyboard navigation:** All interactive elements reachable via Tab, activatable via Enter/Space
5. **Focus management:** Set focus to main content on route change, trap focus in modals
6. **Color contrast:** Minimum 4.5:1 for normal text, 3:1 for large text
7. **Alt text:** All `<img>` tags have descriptive `alt` (or `alt=""` for decorative images)
## Examples
### User List Page with Search and Pagination
```tsx
export default function UserListPage() {
const [search, setSearch] = useState("");
const debouncedSearch = useDebounce(search, 300);
const pagination = usePagination();
const { data, isPending } = useQuery(
userQueries.list({ q: debouncedSearch, cursor: pagination.cursor })
);
return (
<main>
<h1>Users</h1>
<input
type="search"
value={search}
onChange={(e) => { setSearch(e.target.value); pagination.reset(); }}
placeholder="Search users..."
aria-label="Search users"
/>
{isPending ? <Spinner /> : (
<>
<UserTable users={data.items} />
{data.hasMore && (
<button onClick={() => pagination.goToNext(data.nextCursor)}>
Load more
</button>
)}
</>
)}
</main>
);
}
```
## Edge Cases
- **Stale closures in hooks:** When using callbacks that reference state, use `useRef` for mutable values that change frequently, or include dependencies in useCallback/useEffect arrays.
- **TanStack Query key collisions:** Structure keys hierarchically: `["users"]` for list, `["users", id]` for detail, `["users", { q, page }]` for filtered list. Use `queryOptions()` factory to centralize key definitions.
- **Infinite re-renders:** Common causes: missing dependency arrays, creating new objects/arrays in render (wrap in `useMemo`), state updates in useEffect without proper conditions.
- **Hydration mismatches:** Avoid rendering content that depends on browser-only APIs (window, localStorage) during initial render. Use `useEffect` or check `typeof window !== "undefined"`.
- **Memory leaks:** Cancel async operations in useEffect cleanup. TanStack Query handles this automatically for queries.
See `references/component-templates.md` for annotated component templates.
See `references/tanstack-query-patterns.md` for CRUD query patterns.