# Frontend Standards > **⚠️ MAINTENANCE:** This file is indexed in `dev-team/skills/shared-patterns/standards-coverage-table.md`. > When adding/removing `## ` sections, follow FOUR-FILE UPDATE RULE in CLAUDE.md: (1) edit standards file, (2) update TOC, (3) update standards-coverage-table.md, (4) update agent file. This file defines the specific standards for frontend development. > **Reference**: Always consult `docs/PROJECT_RULES.md` for common project standards. --- ## Table of Contents | # | Section | Description | |---|---------|-------------| | 1 | [Framework](#framework) | React 18+, Next.js (version policy) | | 2 | [Libraries & Tools](#libraries--tools) | Core, state, forms, UI, styling, testing | | 3 | [State Management Patterns](#state-management-patterns) | TanStack Query, Zustand | | 4 | [Form Patterns](#form-patterns) | React Hook Form + Zod | | 5 | [Styling Standards](#styling-standards) | TailwindCSS, CSS variables | | 6 | [Typography Standards](#typography-standards) | Font selection and pairing | | 7 | [Animation Standards](#animation-standards) | CSS transitions, Framer Motion | | 8 | [Component Patterns](#component-patterns) | Compound components, error boundaries | | 9 | [Accessibility](#accessibility) | WCAG 2.1 AA compliance | | 10 | [Performance](#performance) | Code splitting, image optimization | | 11 | [Directory Structure](#directory-structure) | Next.js App Router layout | | 12 | [Forbidden Patterns](#forbidden-patterns) | Anti-patterns to avoid | | 13 | [Standards Compliance Categories](#standards-compliance-categories) | Categories for ring:dev-refactor | | 14 | [Form Field Abstraction Layer](#form-field-abstraction-layer) | **HARD GATE:** Field wrappers, dual-mode (sindarian-ui vs vanilla) | | 15 | [Provider Composition Pattern](#provider-composition-pattern) | Nested providers order, feature providers | | 16 | [Custom Hooks Patterns](#custom-hooks-patterns) | **HARD GATE:** usePagination, useCursorPagination, useCreateUpdateSheet, useStepper, useDebounce | | 17 | [Fetcher Utilities Pattern](#fetcher-utilities-pattern) | getFetcher, postFetcher, patchFetcher, deleteFetcher | | 18 | [Client-Side Error Handling](#client-side-error-handling) | **HARD GATE:** ErrorBoundary, API error helpers, toast integration | | 19 | [Data Table Pattern](#data-table-pattern) | TanStack Table, server-side pagination, column definitions | **Meta-sections (not checked by agents):** - [Checklist](#checklist) - Self-verification before submitting code --- ## Framework - React 18+ - Next.js (see version policy below) - TypeScript strict mode (see `typescript.md`) ### Framework Version Policy | Scenario | Rule | |----------|------| | **New project** | Use **latest stable version** (verify at nextjs.org before starting) | | **Existing codebase** | **Maintain project's current version** (read package.json) | **Before starting any project:** 1. For NEW projects: Check https://nextjs.org for latest stable version 2. For EXISTING projects: Read `package.json` to determine current version 3. NEVER hardcode a specific version in implementation - use project's version --- ## Libraries & Tools ### Core | Library | Use Case | |---------|----------| | React 18+ | UI framework | | Next.js (latest stable) | Full-stack framework (see version policy above) | | TypeScript 5+ | Type safety | ### State Management | Library | Use Case | |---------|----------| | TanStack Query | Server state (API data) | | Zustand | Client state (UI state) | | Context API | Simple shared state | | Redux Toolkit | Complex global state | ### Forms | Library | Use Case | |---------|----------| | React Hook Form | Form state management | | Zod | Schema validation | | @hookform/resolvers | RHF + Zod integration | ### UI Components | Library | Use Case | |---------|----------| | Radix UI | Headless primitives | | shadcn/ui | Pre-styled Radix components | | Chakra UI | Full component library | | Headless UI | Tailwind-native primitives | ### Styling | Library | Use Case | |---------|----------| | TailwindCSS | Utility-first CSS | | CSS Modules | Scoped CSS | | Styled Components | CSS-in-JS | | CSS Variables | Theming | ### Testing | Library | Use Case | |---------|----------| | Vitest | Unit tests | | Testing Library | Component tests | | Playwright | E2E tests | | MSW | API mocking | --- ## State Management Patterns ### Server State with TanStack Query ```typescript import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; // Query key factory const userKeys = { all: ['users'] as const, lists: () => [...userKeys.all, 'list'] as const, list: (filters: UserFilters) => [...userKeys.lists(), filters] as const, details: () => [...userKeys.all, 'detail'] as const, detail: (id: string) => [...userKeys.details(), id] as const, }; // Typed query hook function useUser(userId: string) { return useQuery({ queryKey: userKeys.detail(userId), queryFn: () => fetchUser(userId), staleTime: 5 * 60 * 1000, // 5 minutes }); } // Mutation with cache update function useCreateUser() { const queryClient = useQueryClient(); return useMutation({ mutationFn: createUser, onSuccess: (newUser) => { // Update cache queryClient.setQueryData( userKeys.detail(newUser.id), newUser ); // Invalidate list queryClient.invalidateQueries({ queryKey: userKeys.lists(), }); }, }); } ``` ### Client State with Zustand ```typescript import { create } from 'zustand'; import { persist } from 'zustand/middleware'; interface UIState { theme: 'light' | 'dark'; sidebarOpen: boolean; setTheme: (theme: 'light' | 'dark') => void; toggleSidebar: () => void; } const useUIStore = create()( persist( (set) => ({ theme: 'light', sidebarOpen: true, setTheme: (theme) => set({ theme }), toggleSidebar: () => set((state) => ({ sidebarOpen: !state.sidebarOpen })), }), { name: 'ui-storage' } ) ); // Usage in component function Header() { const { theme, setTheme } = useUIStore(); return ; } ``` --- ## Form Patterns ### React Hook Form + Zod ```typescript import { useForm } from 'react-hook-form'; import { zodResolver } from '@hookform/resolvers/zod'; import { z } from 'zod'; // Schema const createUserSchema = z.object({ name: z.string().min(1, 'Name is required').max(100), email: z.string().email('Invalid email'), role: z.enum(['admin', 'user', 'guest']), notifications: z.boolean().default(true), }); type CreateUserInput = z.infer; // Component function CreateUserForm() { const { register, handleSubmit, formState: { errors, isSubmitting }, } = useForm({ resolver: zodResolver(createUserSchema), defaultValues: { notifications: true, }, }); const createUser = useCreateUser(); const onSubmit = async (data: CreateUserInput) => { await createUser.mutateAsync(data); }; return (
); } ``` --- ## Styling Standards ### TailwindCSS Best Practices ```tsx // Use semantic class groupings
// Extract repeated patterns to components function Card({ children, className }: CardProps) { return (
{children}
); } ``` ### CSS Variables for Theming ```css :root { --color-primary: 220 90% 56%; --color-secondary: 262 83% 58%; --color-background: 0 0% 100%; --color-foreground: 222 47% 11%; --color-muted: 210 40% 96%; --color-border: 214 32% 91%; --radius: 0.5rem; } .dark { --color-background: 222 47% 11%; --color-foreground: 210 40% 98%; --color-muted: 217 33% 17%; --color-border: 217 33% 17%; } ``` ### Mobile-First Responsive Design ```tsx // Always start mobile, scale up
// Responsive text

// Hide/show based on breakpoint
Desktop only
Mobile only
``` --- ## Typography Standards ### Font Selection (AVOID GENERIC) ```tsx // FORBIDDEN - Generic AI fonts font-family: 'Inter', sans-serif; // Too common font-family: 'Roboto', sans-serif; // Too common font-family: 'Arial', sans-serif; // System font font-family: system-ui, sans-serif; // System stack // RECOMMENDED - Distinctive fonts font-family: 'Geist', sans-serif; // Modern, tech font-family: 'Satoshi', sans-serif; // Contemporary font-family: 'Cabinet Grotesk', sans-serif; // Bold, editorial font-family: 'Clash Display', sans-serif; // Display headings font-family: 'General Sans', sans-serif; // Clean, versatile ``` ### Font Pairing ```css /* Display + Body pairing */ --font-display: 'Clash Display', sans-serif; --font-body: 'Satoshi', sans-serif; /* Heading uses display */ h1, h2, h3 { font-family: var(--font-display); } /* Body uses readable font */ body, p, span { font-family: var(--font-body); } ``` --- ## Animation Standards ### CSS Transitions (Simple Effects) ```css /* Standard transition */ .button { transition: all 150ms ease; } /* Specific properties for performance */ .card { transition: transform 200ms ease, box-shadow 200ms ease; } /* Hover states */ .card:hover { transform: translateY(-2px); box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1); } ``` ### Framer Motion (Complex Animations) ```tsx import { motion, AnimatePresence } from 'framer-motion'; // Page transitions function PageWrapper({ children }: { children: React.ReactNode }) { return ( {children} ); } // Staggered list animation function ItemList({ items }: { items: Item[] }) { return ( {items.map((item, i) => ( {item.name} ))} ); } ``` ### Animation Guidelines 1. **Focus on high-impact moments** - Page loads, modal opens, state changes 2. **One orchestrated animation > scattered micro-interactions** 3. **Keep durations short** - 150-300ms for UI, 300-500ms for page transitions 4. **Use easing** - `ease`, `ease-out` for exits, `ease-in-out` for continuous --- ## Component Patterns ### Compound Components ```tsx // Flexible API for complex components function Tabs({ children, defaultValue }: TabsProps) { const [value, setValue] = useState(defaultValue); return (
{children}
); } Tabs.List = function TabsList({ children }: { children: React.ReactNode }) { return
{children}
; }; Tabs.Trigger = function TabsTrigger({ value, children }: TabsTriggerProps) { const { value: selected, setValue } = useTabsContext(); return ( ); }; Tabs.Content = function TabsContent({ value, children }: TabsContentProps) { const { value: selected } = useTabsContext(); if (value !== selected) return null; return
{children}
; }; // Usage Tab 1 Tab 2 Content 1 Content 2 ``` ### Error Boundaries ```tsx import { Component, ErrorInfo, ReactNode } from 'react'; interface Props { children: ReactNode; fallback: ReactNode; } interface State { hasError: boolean; } class ErrorBoundary extends Component { state: State = { hasError: false }; static getDerivedStateFromError(): State { return { hasError: true }; } componentDidCatch(error: Error, errorInfo: ErrorInfo) { console.error('Error:', error, errorInfo); } render() { if (this.state.hasError) { return this.props.fallback; } return this.props.children; } } // Usage }> ``` --- ## Accessibility ### Required Practices ```tsx // Always use semantic HTML // not
// Images need alt text {`${user.name}'s // Form inputs need labels // Use ARIA when needed // Keyboard navigation
e.key === 'Enter' && onClick()} onClick={onClick} > ``` ### Focus Management ```tsx // Focus trap for modals import { FocusTrap } from '@radix-ui/react-focus-scope'; ... // Auto-focus on mount const inputRef = useRef(null); useEffect(() => { inputRef.current?.focus(); }, []); ``` --- ## Performance ### Code Splitting ```tsx import { lazy, Suspense } from 'react'; // Lazy load heavy components const Dashboard = lazy(() => import('./pages/Dashboard')); const Analytics = lazy(() => import('./pages/Analytics')); // Use Suspense }> ``` ### Image Optimization ```tsx import Image from 'next/image'; // Always use next/image {user.name} ``` ### Memoization ```tsx // Memo expensive components const ExpensiveList = memo(function ExpensiveList({ items }: Props) { return items.map(item => ); }); // useMemo for expensive calculations const sortedItems = useMemo( () => items.sort((a, b) => b.score - a.score), [items] ); // useCallback for stable references const handleClick = useCallback((id: string) => { setSelectedId(id); }, []); ``` --- ## Directory Structure ```text /src /app # Next.js App Router /api # API routes /(auth) # Auth route group /(dashboard) # Dashboard route group layout.tsx page.tsx /components /ui # Primitive UI components button.tsx input.tsx card.tsx /features # Feature-specific components /user UserProfile.tsx UserList.tsx /order OrderForm.tsx /hooks # Custom hooks useUser.ts useDebounce.ts /lib # Utilities api.ts utils.ts cn.ts /stores # Zustand stores userStore.ts uiStore.ts /types # TypeScript types user.ts api.ts /public # Static assets ``` --- ## Forbidden Patterns **The following patterns are never allowed. Agents MUST refuse to implement these:** ### TypeScript Anti-Patterns | Pattern | Why Forbidden | Correct Alternative | |---------|---------------|---------------------| | `any` type | Defeats TypeScript purpose | Use proper types, `unknown`, or generics | | Type assertions without validation | Runtime errors | Use type guards or Zod parsing | | `// @ts-ignore` or `// @ts-expect-error` | Hides real errors | Fix the type issue properly | | Non-strict mode | Allows unsafe code | Enable `"strict": true` in tsconfig | ### Accessibility Anti-Patterns | Pattern | Why Forbidden | Correct Alternative | |---------|---------------|---------------------| | `
` for buttons | Not keyboard accessible | Use ` ); } ``` ### Anti-Patterns (FORBIDDEN) | Pattern | Why Forbidden | Correct Alternative | |---------|---------------|---------------------| | `` directly | No label, no error display, no accessibility | Use `` | | Inline error handling | Inconsistent UX | Use FormMessage from wrapper | | Manual FormField for each input | Code duplication | Use pre-built field wrappers | | Different field patterns per form | Inconsistent UX | Use shared field components | --- ## Provider Composition Pattern ### Provider Order (MANDATORY) Providers MUST be composed in a specific order to ensure proper context availability. ```tsx // src/app/providers.tsx 'use client'; import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; import { SessionProvider } from 'next-auth/react'; import { ThemeProvider } from 'next-themes'; import { Toaster } from '@/components/ui/toaster'; import { TooltipProvider } from '@/components/ui/tooltip'; import { useState } from 'react'; interface ProvidersProps { children: React.ReactNode; } export function Providers({ children }: ProvidersProps) { const [queryClient] = useState( () => new QueryClient({ defaultOptions: { queries: { staleTime: 60 * 1000, // 1 minute refetchOnWindowFocus: false, }, }, }) ); return ( {children} ); } ``` ### Provider Order Rules | Order | Provider | Reason | |-------|----------|--------| | 1 | SessionProvider | Auth must be outermost for all components to access session | | 2 | QueryClientProvider | Data fetching needs session for authenticated requests | | 3 | ThemeProvider | Theme should wrap UI components | | 4 | TooltipProvider | Radix tooltips need provider context | | 5 | App-specific providers | Feature-specific contexts | ### Layout Integration ```tsx // src/app/layout.tsx import { Providers } from './providers'; export default function RootLayout({ children, }: { children: React.ReactNode; }) { return ( {children} ); } ``` ### Feature-Specific Providers For feature-specific state, create scoped providers: ```tsx // src/features/organization/providers/OrganizationProvider.tsx 'use client'; import { createContext, useContext, useState } from 'react'; interface OrganizationContextValue { organizationId: string | null; setOrganizationId: (id: string | null) => void; } const OrganizationContext = createContext(null); export function OrganizationProvider({ children }: { children: React.ReactNode }) { const [organizationId, setOrganizationId] = useState(null); return ( {children} ); } export function useOrganization() { const context = useContext(OrganizationContext); if (!context) { throw new Error('useOrganization must be used within OrganizationProvider'); } return context; } ``` --- ## Custom Hooks Patterns ### Pagination Hooks (MANDATORY for lists) #### usePagination (Offset-based) ```tsx import { useState, useCallback, useMemo } from 'react'; interface UsePaginationOptions { initialPage?: number; initialPageSize?: number; pageSizeOptions?: number[]; } interface UsePaginationReturn { page: number; pageSize: number; offset: number; setPage: (page: number) => void; setPageSize: (size: number) => void; nextPage: () => void; prevPage: () => void; canNextPage: (totalItems: number) => boolean; canPrevPage: boolean; pageSizeOptions: number[]; totalPages: (totalItems: number) => number; } export function usePagination({ initialPage = 1, initialPageSize = 10, pageSizeOptions = [10, 20, 50, 100], }: UsePaginationOptions = {}): UsePaginationReturn { const [page, setPage] = useState(initialPage); const [pageSize, setPageSize] = useState(initialPageSize); const offset = useMemo(() => (page - 1) * pageSize, [page, pageSize]); const nextPage = useCallback(() => setPage((p) => p + 1), []); const prevPage = useCallback(() => setPage((p) => Math.max(1, p - 1)), []); const canNextPage = useCallback( (totalItems: number) => page * pageSize < totalItems, [page, pageSize] ); const canPrevPage = page > 1; const totalPages = useCallback( (totalItems: number) => Math.ceil(totalItems / pageSize), [pageSize] ); const handleSetPageSize = useCallback((size: number) => { setPageSize(size); setPage(1); // Reset to first page on size change }, []); return { page, pageSize, offset, setPage, setPageSize: handleSetPageSize, nextPage, prevPage, canNextPage, canPrevPage, pageSizeOptions, totalPages, }; } ``` #### useCursorPagination (Cursor-based) ```tsx import { useState, useCallback } from 'react'; interface CursorPaginationState { cursor: string | null; direction: 'next' | 'prev'; } interface UseCursorPaginationOptions { initialPageSize?: number; } interface UseCursorPaginationReturn { cursor: string | null; pageSize: number; setPageSize: (size: number) => void; goToNext: (nextCursor: string) => void; goToPrev: (prevCursor: string) => void; reset: () => void; hasNext: boolean; hasPrev: boolean; setHasNext: (value: boolean) => void; setHasPrev: (value: boolean) => void; } export function useCursorPagination({ initialPageSize = 10, }: UseCursorPaginationOptions = {}): UseCursorPaginationReturn { const [state, setState] = useState({ cursor: null, direction: 'next', }); const [pageSize, setPageSize] = useState(initialPageSize); const [hasNext, setHasNext] = useState(false); const [hasPrev, setHasPrev] = useState(false); const goToNext = useCallback((nextCursor: string) => { setState({ cursor: nextCursor, direction: 'next' }); }, []); const goToPrev = useCallback((prevCursor: string) => { setState({ cursor: prevCursor, direction: 'prev' }); }, []); const reset = useCallback(() => { setState({ cursor: null, direction: 'next' }); }, []); return { cursor: state.cursor, pageSize, setPageSize, goToNext, goToPrev, reset, hasNext, hasPrev, setHasNext, setHasPrev, }; } ``` ### CRUD Sheet Hook Pattern ```tsx import { useState, useCallback } from 'react'; type SheetMode = 'create' | 'edit' | 'view' | 'closed'; interface UseSheetOptions { onSuccess?: (data: T) => void; } interface UseSheetReturn { isOpen: boolean; mode: SheetMode; data: T | null; openCreate: () => void; openEdit: (item: T) => void; openView: (item: T) => void; close: () => void; isCreateMode: boolean; isEditMode: boolean; isViewMode: boolean; } export function useCreateUpdateSheet({ onSuccess, }: UseSheetOptions = {}): UseSheetReturn { const [mode, setMode] = useState('closed'); const [data, setData] = useState(null); const openCreate = useCallback(() => { setData(null); setMode('create'); }, []); const openEdit = useCallback((item: T) => { setData(item); setMode('edit'); }, []); const openView = useCallback((item: T) => { setData(item); setMode('view'); }, []); const close = useCallback(() => { setMode('closed'); setData(null); }, []); return { isOpen: mode !== 'closed', mode, data, openCreate, openEdit, openView, close, isCreateMode: mode === 'create', isEditMode: mode === 'edit', isViewMode: mode === 'view', }; } ``` ### Utility Hooks #### useDebounce ```tsx import { useState, useEffect } from 'react'; export function useDebounce(value: T, delay: number = 300): T { const [debouncedValue, setDebouncedValue] = useState(value); useEffect(() => { const timer = setTimeout(() => setDebouncedValue(value), delay); return () => clearTimeout(timer); }, [value, delay]); return debouncedValue; } ``` #### useStepper ```tsx import { useState, useCallback } from 'react'; interface UseStepperOptions { initialStep?: number; totalSteps: number; } interface UseStepperReturn { currentStep: number; totalSteps: number; isFirstStep: boolean; isLastStep: boolean; nextStep: () => void; prevStep: () => void; goToStep: (step: number) => void; reset: () => void; progress: number; } export function useStepper({ initialStep = 0, totalSteps, }: UseStepperOptions): UseStepperReturn { const [currentStep, setCurrentStep] = useState(initialStep); const nextStep = useCallback(() => { setCurrentStep((s) => Math.min(s + 1, totalSteps - 1)); }, [totalSteps]); const prevStep = useCallback(() => { setCurrentStep((s) => Math.max(s - 1, 0)); }, []); const goToStep = useCallback( (step: number) => { if (step >= 0 && step < totalSteps) { setCurrentStep(step); } }, [totalSteps] ); const reset = useCallback(() => setCurrentStep(initialStep), [initialStep]); return { currentStep, totalSteps, isFirstStep: currentStep === 0, isLastStep: currentStep === totalSteps - 1, nextStep, prevStep, goToStep, reset, progress: ((currentStep + 1) / totalSteps) * 100, }; } ``` --- ## Fetcher Utilities Pattern ### Base Fetcher Functions ```tsx // src/lib/fetcher/index.ts export interface FetcherOptions extends RequestInit { params?: Record; } function buildUrl(url: string, params?: FetcherOptions['params']): string { if (!params) return url; const searchParams = new URLSearchParams(); Object.entries(params).forEach(([key, value]) => { if (value !== undefined) { searchParams.append(key, String(value)); } }); const queryString = searchParams.toString(); return queryString ? `${url}?${queryString}` : url; } async function handleResponse(response: Response): Promise { if (!response.ok) { const error = await response.json().catch(() => ({})); throw new ApiError( error.message || 'Request failed', response.status, error.code ); } return response.json(); } export async function getFetcher( url: string, options: FetcherOptions = {} ): Promise { const { params, ...fetchOptions } = options; const response = await fetch(buildUrl(url, params), { method: 'GET', headers: { 'Content-Type': 'application/json', ...fetchOptions.headers, }, ...fetchOptions, }); return handleResponse(response); } export async function postFetcher( url: string, data: D, options: FetcherOptions = {} ): Promise { const { params, ...fetchOptions } = options; const response = await fetch(buildUrl(url, params), { method: 'POST', headers: { 'Content-Type': 'application/json', ...fetchOptions.headers, }, body: JSON.stringify(data), ...fetchOptions, }); return handleResponse(response); } export async function patchFetcher( url: string, data: D, options: FetcherOptions = {} ): Promise { const { params, ...fetchOptions } = options; const response = await fetch(buildUrl(url, params), { method: 'PATCH', headers: { 'Content-Type': 'application/json', ...fetchOptions.headers, }, body: JSON.stringify(data), ...fetchOptions, }); return handleResponse(response); } export async function deleteFetcher( url: string, options: FetcherOptions = {} ): Promise { const { params, ...fetchOptions } = options; const response = await fetch(buildUrl(url, params), { method: 'DELETE', headers: { 'Content-Type': 'application/json', ...fetchOptions.headers, }, ...fetchOptions, }); return handleResponse(response); } ``` ### ApiError Class ```tsx // src/lib/fetcher/api-error.ts export class ApiError extends Error { constructor( message: string, public status: number, public code?: string ) { super(message); this.name = 'ApiError'; } get isNotFound() { return this.status === 404; } get isUnauthorized() { return this.status === 401; } get isForbidden() { return this.status === 403; } get isValidationError() { return this.status === 400 || this.status === 422; } get isServerError() { return this.status >= 500; } } ``` ### Integration with TanStack Query ```tsx // src/hooks/use-users.ts import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; import { getFetcher, postFetcher, patchFetcher, deleteFetcher } from '@/lib/fetcher'; import type { User, CreateUserInput, UpdateUserInput } from '@/types/user'; const userKeys = { all: ['users'] as const, lists: () => [...userKeys.all, 'list'] as const, list: (filters: Record) => [...userKeys.lists(), filters] as const, details: () => [...userKeys.all, 'detail'] as const, detail: (id: string) => [...userKeys.details(), id] as const, }; export function useUsers(filters: { page?: number; pageSize?: number } = {}) { return useQuery({ queryKey: userKeys.list(filters), queryFn: () => getFetcher<{ data: User[]; total: number }>('/api/users', { params: filters, }), }); } export function useUser(id: string) { return useQuery({ queryKey: userKeys.detail(id), queryFn: () => getFetcher(`/api/users/${id}`), enabled: !!id, }); } export function useCreateUser() { const queryClient = useQueryClient(); return useMutation({ mutationFn: (data: CreateUserInput) => postFetcher('/api/users', data), onSuccess: () => { queryClient.invalidateQueries({ queryKey: userKeys.lists() }); }, }); } export function useUpdateUser(id: string) { const queryClient = useQueryClient(); return useMutation({ mutationFn: (data: UpdateUserInput) => patchFetcher(`/api/users/${id}`, data), onSuccess: (updatedUser) => { queryClient.setQueryData(userKeys.detail(id), updatedUser); queryClient.invalidateQueries({ queryKey: userKeys.lists() }); }, }); } export function useDeleteUser() { const queryClient = useQueryClient(); return useMutation({ mutationFn: (id: string) => deleteFetcher(`/api/users/${id}`), onSuccess: () => { queryClient.invalidateQueries({ queryKey: userKeys.lists() }); }, }); } ``` --- ## Client-Side Error Handling ### ErrorBoundary Component ```tsx // src/components/error-boundary.tsx 'use client'; import { Component, ErrorInfo, ReactNode } from 'react'; import { Button } from '@/components/ui/button'; import { AlertTriangle } from 'lucide-react'; interface ErrorBoundaryProps { children: ReactNode; fallback?: ReactNode; onReset?: () => void; } interface ErrorBoundaryState { hasError: boolean; error: Error | null; } export class ErrorBoundary extends Component { constructor(props: ErrorBoundaryProps) { super(props); this.state = { hasError: false, error: null }; } static getDerivedStateFromError(error: Error): ErrorBoundaryState { return { hasError: true, error }; } componentDidCatch(error: Error, errorInfo: ErrorInfo) { console.error('ErrorBoundary caught an error:', error, errorInfo); // Report to error tracking service (e.g., Sentry) } handleReset = () => { this.setState({ hasError: false, error: null }); this.props.onReset?.(); }; render() { if (this.state.hasError) { if (this.props.fallback) { return this.props.fallback; } return (

Something went wrong

{this.state.error?.message || 'An unexpected error occurred'}

); } return this.props.children; } } ``` ### API Error Helpers ```tsx // src/lib/error-helpers.ts import { toast } from '@/components/ui/use-toast'; import { ApiError } from '@/lib/fetcher/api-error'; export function handleApiError(error: unknown): void { if (error instanceof ApiError) { if (error.isUnauthorized) { toast({ variant: 'destructive', title: 'Session Expired', description: 'Please log in again.', }); // Redirect to login window.location.href = '/login'; return; } if (error.isForbidden) { toast({ variant: 'destructive', title: 'Access Denied', description: 'You do not have permission to perform this action.', }); return; } if (error.isValidationError) { toast({ variant: 'destructive', title: 'Validation Error', description: error.message, }); return; } if (error.isServerError) { toast({ variant: 'destructive', title: 'Server Error', description: 'Something went wrong. Please try again later.', }); return; } toast({ variant: 'destructive', title: 'Error', description: error.message, }); return; } // Unknown error toast({ variant: 'destructive', title: 'Error', description: 'An unexpected error occurred.', }); } ``` ### Error Recovery Patterns ```tsx // Using with TanStack Query mutations import { useToast } from '@/components/ui/use-toast'; import { handleApiError } from '@/lib/error-helpers'; function CreateUserForm() { const { toast } = useToast(); const createUser = useCreateUser(); const onSubmit = async (data: CreateUserInput) => { try { await createUser.mutateAsync(data); toast({ title: 'Success', description: 'User created successfully.', }); } catch (error) { handleApiError(error); } }; return (
{/* form fields */} {createUser.isError && ( Failed to create user. Please try again. )}
); } ``` ### Query Error Handling ```tsx // Global error handler for React Query import { QueryClient } from '@tanstack/react-query'; import { handleApiError } from '@/lib/error-helpers'; export const queryClient = new QueryClient({ defaultOptions: { queries: { retry: (failureCount, error) => { // Don't retry on 4xx errors if (error instanceof ApiError && error.status < 500) { return false; } return failureCount < 3; }, }, mutations: { onError: (error) => { handleApiError(error); }, }, }, }); ``` --- ## Data Table Pattern ### TanStack Table with Pagination ```tsx // src/components/data-table.tsx 'use client'; import { ColumnDef, flexRender, getCoreRowModel, useReactTable, getPaginationRowModel, SortingState, getSortedRowModel, } from '@tanstack/react-table'; import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow, } from '@/components/ui/table'; import { Button } from '@/components/ui/button'; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue, } from '@/components/ui/select'; import { useState } from 'react'; interface DataTableProps { columns: ColumnDef[]; data: TData[]; pageCount?: number; pagination?: { page: number; pageSize: number; onPageChange: (page: number) => void; onPageSizeChange: (size: number) => void; }; isLoading?: boolean; } export function DataTable({ columns, data, pageCount, pagination, isLoading, }: DataTableProps) { const [sorting, setSorting] = useState([]); const table = useReactTable({ data, columns, getCoreRowModel: getCoreRowModel(), getSortedRowModel: getSortedRowModel(), getPaginationRowModel: getPaginationRowModel(), onSortingChange: setSorting, state: { sorting, }, manualPagination: !!pagination, pageCount: pageCount, }); return (
{table.getHeaderGroups().map((headerGroup) => ( {headerGroup.headers.map((header) => ( {header.isPlaceholder ? null : flexRender( header.column.columnDef.header, header.getContext() )} ))} ))} {isLoading ? ( Loading... ) : table.getRowModel().rows?.length ? ( table.getRowModel().rows.map((row) => ( {row.getVisibleCells().map((cell) => ( {flexRender( cell.column.columnDef.cell, cell.getContext() )} ))} )) ) : ( No results. )}
{pagination && (

Rows per page

Page {pagination.page} of {pageCount || 1}
)}
); } ``` ### Usage with Server-Side Pagination ```tsx // src/app/users/page.tsx 'use client'; import { DataTable } from '@/components/data-table'; import { useUsers } from '@/hooks/use-users'; import { usePagination } from '@/hooks/use-pagination'; import { columns } from './columns'; export default function UsersPage() { const pagination = usePagination({ initialPageSize: 20 }); const { data, isLoading } = useUsers({ page: pagination.page, pageSize: pagination.pageSize, }); return ( ); } ``` ### Column Definitions Pattern ```tsx // src/app/users/columns.tsx import { ColumnDef } from '@tanstack/react-table'; import { User } from '@/types/user'; import { Button } from '@/components/ui/button'; import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger, } from '@/components/ui/dropdown-menu'; import { MoreHorizontal, Pencil, Trash } from 'lucide-react'; export const columns: ColumnDef[] = [ { accessorKey: 'name', header: 'Name', }, { accessorKey: 'email', header: 'Email', }, { accessorKey: 'role', header: 'Role', cell: ({ row }) => ( {row.getValue('role')} ), }, { accessorKey: 'createdAt', header: 'Created', cell: ({ row }) => ( new Date(row.getValue('createdAt')).toLocaleDateString() ), }, { id: 'actions', cell: ({ row }) => { const user = row.original; return ( handleEdit(user)}> Edit handleDelete(user.id)} className="text-destructive" > Delete ); }, }, ]; ``` --- ## Checklist Before submitting frontend code, verify: - [ ] TypeScript strict mode (no `any`) - [ ] Components use semantic HTML - [ ] Forms validated with Zod - [ ] TanStack Query for server state - [ ] Zustand for client state (if needed) - [ ] Mobile-first responsive design - [ ] Keyboard accessible (tabIndex, onKeyDown) - [ ] ARIA labels where needed - [ ] Images use next/image with alt text - [ ] No generic fonts (Inter, Roboto, Arial) - [ ] Animations are purposeful, not decorative - [ ] No FORBIDDEN patterns present