
React Development
- 11 installs
- 7 repo stars
- Updated August 2, 2026
- practicalswan/agent-skills
react-development is a Claude Code skill for frontend development.
About
react-development is a Claude Code skill for frontend development. It helps solo builders move faster with AI-assisted development.
- react-development
- Frontend Development
- AI-coding skill
React Development by the numbers
- 11 all-time installs (skills.sh)
- Ranked #1,658 of 2,245 Frontend Development skills by installs in the Skillselion catalog
- Data as of Aug 4, 2026 (Skillselion catalog sync)
npx skills add https://github.com/practicalswan/agent-skills --skill react-developmentAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 11 |
|---|---|
| repo stars | ★ 7 |
| Last updated | August 2, 2026 |
| Repository | practicalswan/agent-skills ↗ |
How do I helps with frontend development tasks.?
Helps with frontend development tasks.
Who is it for?
Best when you're working on frontend development and need structured help with react development.
Skip if: Teams with no frontend development needs, or anyone wanting a generic chat assistant without this specific workflow.
When should I use this skill?
When you need to helps with frontend development tasks., or when react-development is a claude code skill for frontend development.
What you get
Structured output aligned to react-development: react-development, Frontend Development.
Files
React Development
Optimized for React 19+, TypeScript 5.5+, React Testing Library 16+, and modern server-first app architectures.
Expert guidance for building high-quality React applications with React 19+, modern hooks, TypeScript, and best practices following official React documentation at https://react.dev.
- Leverage native parallel subagent dispatch and 200k+ context windows where available.
Component Review Rubric Reference
Apply the shared Component Review Rubric before approving React components, then run the React-specific checks below.
Anti-Patterns
- Fetching everything in client effects by default: Server-first data loading is usually simpler, faster, and easier to cache.
- Adding memoization before profiling: Manual optimizations can create stale-prop bugs and hide simpler design fixes.
- Skipping keyboard and accessible-name review: A component is not done if only pointer users can operate it reliably.
Verification Protocol
Before claiming "skill applied successfully":
1. Pass/fail: The React Development guidance is tied to a concrete route, component, screen, or design artifact. 2. Pass/fail: Component states cover loading, empty, error, success, and responsive breakpoints where applicable. 3. Pass/fail: Accessibility, visual hierarchy, and interaction behavior are reviewed against the shared component rubric. 4. Pressure-test scenario: Review the component on a narrow mobile viewport, keyboard-only path, and slow-loading state. 5. Success metric: Zero generic UI approval; every approval cites rendered behavior or source evidence.
Before and After Example
// Before
'use client';
export function Dashboard() {
const [orders, setOrders] = useState<Order[]>([]);
useEffect(() => {
fetch('/api/orders').then((r) => r.json()).then(setOrders);
}, []);
return <OrdersTable orders={orders} />;
}
// After
export async function Dashboard() {
const orders = await getOrders();
return <OrdersTable orders={orders} />;
}Lets the server boundary load data directly and keeps the client side focused on interaction, not bootstrapping.
Activation Conditions
Use symptom -> action triggers: when one matches, apply this skill and verify with the protocol below.
Core React Development:
- Building React components with hooks and TypeScript
- Setting up React applications (Vite, Next.js, CRA)
- Working with React Router for navigation
- Implementing forms and user input handling
- Creating responsive layouts and component architecture
State Management & Data:
- Managing state (useState, useReducer, useContext)
- Implementing React Query or SWR for data fetching
- Building custom hooks for reusable stateful logic
- Creating Context providers for global state
- Implementing React 19 Server Components (if using Next.js)
Component Patterns:
- Creating reusable UI components with proper composition
- Building forms with React Hook Form and Zod validation
- Implementing modal dialogs and overlays
- Creating custom React hooks (useDebounce, useLocalStorage)
- Designing responsive variants for different screen sizes
Performance & Quality:
- Optimizing React app performance
- Implementing memoization (useMemo, useCallback)
- Code splitting and lazy loading
- Writing tests with React Testing Library
- Ensuring accessibility compliance (ARIA, keyboard nav)
Part 1: React Fundamentals
Project Structure
Recommended Structure:
src/
├── components/ # Reusable UI components
│ ├── ui/ # Primitives (Button, Input, Modal)
│ ├── forms/ # Form components
│ └── layout/ # Layout components
├── hooks/ # Custom hooks
├── contexts/ # Context providers
├── pages/ # Page components
├── lib/ # Utilities, API clients
├── types/ # TypeScript types
├── styles/ # Global styles
└── main.jsx # App entry pointComponent Architecture
Functional Components (React 19 Standard)
interface ButtonProps {
variant: 'primary' | 'secondary' | 'danger';
size?: 'sm' | 'md' | 'lg';
onClick: (event: React.MouseEvent<HTMLButtonElement>) => void;
children: React.ReactNode;
disabled?: boolean;
isLoading?: boolean;
}
export function Button({
variant = 'primary',
size = 'md',
onClick,
children,
disabled = false,
isLoading = false,
}: ButtonProps) {
const baseClasses = 'rounded-lg font-medium transition-colors focus:outline-none focus:ring-2 focus:ring-offset-2';
const variantClasses = {
primary: 'bg-blue-600 hover:bg-blue-700 text-white focus:ring-blue-500',
secondary: 'bg-gray-100 hover:bg-gray-200 text-gray-900 focus:ring-gray-500',
danger: 'bg-red-600 hover:bg-red-700 text-white focus:ring-red-500',
};
const sizeClasses = {
sm: 'px-3 py-1.5 text-sm',
md: 'px-4 py-2 text-base',
lg: 'px-5 py-2.5 text-lg',
};
return (
<button
disabled={disabled || isLoading}
onClick={onClick}
className={`${baseClasses} ${variantClasses[variant]} ${sizeClasses[size]}`}
>
{isLoading ? 'Loading...' : children}
</button>
);
}Generic Components
interface ListProps<T> {
items: T[];
renderItem: (item: T, index: number) => React.ReactNode;
keyExtractor: (item: T) => string;
emptyMessage?: string;
}
function List<T>({ items, renderItem, keyExtractor, emptyMessage = 'No items' }: ListProps<T>) {
return (
<ul>
{items.length === 0 ? (
<li>{emptyMessage}</li>
) : (
items.map((item, index) => (
<li key={keyExtractor(item)}>
{renderItem(item, index)}
</li>
))
)}
</ul>
);
}
// Usage
<List
items={users}
renderItem={(user) => `${user.firstName} ${user.lastName}`}
keyExtractor={(user) => user.id}
/>Component Patterns
Composition over Inheritance:
// ✅ GOOD - Component composition
function Card({ children }: { children: React.ReactNode }) {
return (
<div className="border rounded-lg p-4 shadow-sm">
{children}
</div>
);
}
function CardHeader({ children }: { children: React.ReactNode }) {
return (
<div className="font-bold text-lg mb-2 border-b pb-2">
{children}
</div>
);
}
function CardBody({ children }: { children: React.ReactNode }) {
return <div>{children}</div>;
}
// Usage
<Card>
<CardHeader>Title</CardHeader>
<CardBody>Content here</CardBody>
</Card>
// ❌ BAD - Inheritance or complex props
function Card({ renderHeader, renderBody, renderFooter }: { ... }) { ... }Presentational vs Container Components:
// Presentational - UI only, doesn't fetch data
interface UserCardProps {
user: User;
onStatusChange: (status: string) => void;
}
function UserCard({ user, onStatusChange }: UserCardProps) {
return (
<div>
<h3>{user.name}</h3>
<select value={user.status} onChange={(e) => onStatusChange(e.target.value)}>
<option value="active">Active</option>
<option value="inactive">Inactive</option>
</select>
</div>
);
}
// Container - Fetches data, passes to presentational
function UserContainer() {
const { data: user, mutate: updateStatus } = useUser(userId);
return user ? (
<UserCard user={user} onStatusChange={(status) => updateStatus({ status })} />
) : (
<LoadingSpinner />
);
}---
Part 2: Hooks Patterns
Built-in Hooks Mastery
useState - For Local State
// Simple state
function Counter() {
const [count, setCount] = useState(0);
return (
<div>
<span>{count}</span>
<button onClick={() => setCount(c => c + 1)}>Increment</button>
</div>
);
}
// Complex state - use useReducer instead
function Form() {
type FormState = {
name: string;
email: string;
submitted: boolean;
};
const [form, setForm] = useState<FormState>({
name: '',
email: '',
submitted: false,
});
const updateField = (field: keyof FormState) => (
e: React.ChangeEvent<HTMLInputElement>
) => setForm(f => ({ ...f, [field]: e.target.value }));
return (
<form>
<input value={form.name} onChange={updateField('name')} />
<input value={form.email} onChange={updateField('email')} />
</form>
);
}useReducer - For Complex State
type Action =
| { type: 'ADD_TODO'; payload: string }
| { type: 'TOGGLE_TODO'; payload: number }
| { type: 'DELETE_TODO'; payload: number };
type TodoState = {
items: Array<{ id: number; text: string; completed: boolean }>;
};
function todosReducer(state: TodoState, action: Action): TodoState {
switch (action.type) {
case 'ADD_TODO':
return {
items: [
...state.items,
{ id: Date.now(), text: action.payload, completed: false },
],
};
case 'TOGGLE_TODO':
return {
items: state.items.map((item) =>
item.id === action.payload ? { ...item, completed: !item.completed } : item
),
};
case 'DELETE_TODO':
return {
items: state.items.filter((item) => item.id !== action.payload),
};
default:
return state;
}
}
function TodoApp() {
const [state, dispatch] = useReducer(todosReducer, { items: [] });
return (
<div>
<ToDoList items={state.items} onToggle={(id) => dispatch({ type: 'TOGGLE_TODO', payload: id })} />
</div>
);
}useEffect - For Side Effects
function UserProfile({ userId }: { userId: string }) {
const [user, setUser] = useState<User | null>(null);
// ✅ GOOD - Run on mount and when userId changes
useEffect(() => {
let cancelled = false;
async function fetchUser() {
const userData = await api.getUser(userId);
if (!cancelled) {
setUser(userData);
}
}
fetchUser();
return () => {
cancelled = true; // Cleanup to prevent state updates after unmount
};
}, [userId]);
// ✅ GOOD - Document title update
useEffect(() => {
if (user) {
document.title = `${user.name} - Profile`;
}
return () => {
document.title = 'App';
};
}, [user]);
if (!user) return <LoadingSkeleton />;
return (
<div>
<h1>{user.name}</h1>
{/* ... */}
</div>
);
}useContext - For Global State
// Context creation
type ThemeContextType = {
theme: 'light' | 'dark';
toggleTheme: () => void;
};
const ThemeContext = createContext<ThemeContextType | null>(null);
// Provider component
export function ThemeProvider({ children }: { children: React.ReactNode }) {
const [theme, setTheme] = useState<'light' | 'dark'>('light');
const toggleTheme = () => setTheme(t => t === 'light' ? 'dark' : 'light');
return (
<ThemeContext.Provider value={{ theme, toggleTheme }}>
{children}
</ThemeContext.Provider>
);
}
// Custom hook for using context
export function useTheme() {
const context = useContext(ThemeContext);
if (!context) {
throw new Error('useTheme must be used within ThemeProvider');
}
return context;
}
// Usage in component
function ThemedButton() {
const { theme, toggleTheme } = useTheme();
return (
<button
onClick={toggleTheme}
className={theme === 'dark' ? 'bg-gray-800 text-white' : 'bg-white text-black'}
>
Toggle {theme} theme
</button>
);
}Performance Hooks
useMemo - Memoize Expensive Computations
function ProductList({ products }: { products: Product[] }) {
// ✅ GOOD - Memoize expensive calculation
const sortedProducts = useMemo(() => {
return [...products].sort((a, b) => a.price - b.price);
}, [products]);
const expensiveAnalysis = useMemo(() => {
// Only recompute when prices change
const avgPrice = products.reduce((sum, p) => sum + p.price, 0) / products.length;
const maxPrice = Math.max(...products.map(p => p.price));
return { avgPrice, maxPrice };
}, [products]);
return (
<div>
<StatsCard data={expensiveAnalysis} />
<ProductCards products={sortedProducts} />
</div>
);
}useCallback - Memoize Functions
function ParentComponent() {
const [items, setItems] = useState<Item[]>([]);
// ✅ GOOD - Memoize callback to prevent child re-renders
const handleItemAdd = useCallback((item: Item) => {
setItems(prev => [...prev, item]);
}, []);
const handleItemRemove = useCallback((id: string) => {
setItems(prev => prev.filter(item => item.id !== id));
}, []);
return (
<div>
<AddItemForm onAdd={handleItemAdd} />
<ItemList items={items} onRemove={handleItemRemove} />
</div>
);
}
// Child component wrapped in React.memo
const ItemList = React.memo(function ItemList({
items,
onRemove,
}: {
items: Item[];
onRemove: (id: string) => void;
}) {
return (
<ul>
{items.map(item => (
<li key={item.id}>
{item.name}
<button onClick={() => onRemove(item.id)}>Remove</button>
</li>
))}
</ul>
);
});---
Part 3: Custom Hooks
useDebounce
Debounce rapid value changes, useful for search inputs:
import { useState, useEffect } from 'react';
function useDebounce<T>(value: T, delay: number = 500): T {
const [debouncedValue, setDebouncedValue] = useState<T>(value);
useEffect(() => {
const handler = setTimeout(() => {
setDebouncedValue(value);
}, delay);
return () => {
clearTimeout(handler);
};
}, [value, delay]);
return debouncedValue;
}
// Usage
function SearchBar() {
const [query, setQuery] = useState('');
const debouncedQuery = useDebounce(query, 300);
useEffect(() => {
// This only runs 300ms after user stops typing
if (debouncedQuery) {
performSearch(debouncedQuery);
}
}, [debouncedQuery]);
return (
<input
type="text"
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder="Search..."
/>
);
}useLocalStorage
Persist state to localStorage:
function useLocalStorage<T>(key: string, initialValue: T) {
const [storedValue, setStoredValue] = useState<T>(() => {
try {
const item = window.localStorage.getItem(key);
return item ? JSON.parse(item) : initialValue;
} catch (error) {
console.error(`Error reading localStorage key "${key}":`, error);
return initialValue;
}
});
const setValue = (value: T | ((val: T) => T)) => {
try {
const valueToStore = value instanceof Function ? value(storedValue) : value;
setStoredValue(valueToStore);
window.localStorage.setItem(key, JSON.stringify(valueToStore));
} catch (error) {
console.error(`Error setting localStorage key "${key}":`, error);
}
};
return [storedValue, setValue] as const;
}
// Usage
function ThemeToggle() {
const [theme, setTheme] = useLocalStorage<'light' | 'dark'>('theme', 'light');
return (
<button onClick={() => setTheme(theme === 'light' ? 'dark' : 'light')}>
Current theme: {theme}
</button>
);
}useToggle
Toggle boolean state with useful utilities:
function useToggle(initialValue: boolean = false) {
const [value, setValue] = useState(initialValue);
const toggle = useCallback(() => setValue(v => !v), []);
const setTrue = useCallback(() => setValue(true), []);
const setFalse = useCallback(() => setValue(false), []);
return { value, toggle, setTrue, setFalse, setValue } as const;
}
// Usage
function ModalExample() {
const { value: isOpen, toggle, setFalse: close } = useToggle(false);
return (
<>
<button onClick={toggle}>Open Modal</button>
<Modal isOpen={isOpen} onClose={close}>
<p>Modal content</p>
</Modal>
</>
);
}useFetch
Data fetching with loading and error states:
type UseFetchResult<T> = {
data: T | null;
loading: boolean;
error: Error | null;
refetch: () => Promise<void>;
};
function useFetch<T>(url: string): UseFetchResult<T> {
const [data, setData] = useState<T | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<Error | null>(null);
const fetchData = useCallback(async () => {
setLoading(true);
setError(null);
try {
const response = await fetch(url);
const jsonData = await response.json();
setData(jsonData);
} catch (err) {
setError(err as Error);
} finally {
setLoading(false);
}
}, [url]);
useEffect(() => {
fetchData();
}, [fetchData]);
return { data, loading, error, refetch: fetchData };
}
// Usage
function UserProfile({ userId }: { userId: string }) {
const { data: user, loading, error } = useFetch<User>(`/api/users/${userId}`);
if (loading) return <LoadingSpinner />;
if (error) return <ErrorAlert message={error.message} />;
return (
<div>
<h1>{user?.name}</h1>
<p>{user?.email}</p>
</div>
);
}---
Part 4: Forms & Validation
React Hook Form with Zod
import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { z } from 'zod';
// Zod schema for validation
const formSchema = z.object({
name: z.string().min(2, 'Name must be at least 2 characters'),
email: z.string().email('Invalid email address'),
age: z.number().min(18, 'Must be at least 18'),
subscribe: z.boolean().default(false),
});
type FormData = z.infer<typeof formSchema>;
function UserForm() {
const {
register,
handleSubmit,
formState: { errors, isSubmitting },
} = useForm<FormData>({
resolver: zodResolver(formSchema),
});
const onSubmit = async (data: FormData) => {
try {
await api.createUser(data);
alert('User created successfully!');
} catch (error) {
alert('Error creating user');
}
};
return (
<form onSubmit={handleSubmit(onSubmit)}>
<div className="mb-4">
<label>Name</label>
<input {...register('name')} />
{errors.name && <span className="error">{errors.name.message}</span>}
</div>
<div className="mb-4">
<label>Email</label>
<input type="email" {...register('email')} />
{errors.email && <span className="error">{errors.email.message}</span>}
</div>
<div className="mb-4">
<label>Age</label>
<input type="number" {...register('age', { valueAsNumber: true })} />
{errors.age && <span className="error">{errors.age.message}</span>}
</div>
<button type="submit" disabled={isSubmitting}>
{isSubmitting ? 'Creating...' : 'Create User'}
</button>
</form>
);
}Form Components
interface FormFieldProps {
label: string;
error?: string;
children: React.ReactNode;
}
function FormField({ label, error, children }: FormFieldProps) {
return (
<div className="mb-4">
<label className="block text-sm font-medium mb-1">{label}</label>
{children}
{error && <p className="text-red-500 text-sm mt-1">{error}</p>}
</div>
);
}
// Usage
<FormField label="Email" error={errors.email?.message}>
<input
type="email"
{...register('email')}
className="w-full px-3 py-2 border rounded"
/>
</FormField>---
Part 5: Tailwind CSS Integration
Utility Classes for React Components
// Button component with variants
const buttonVariants = {
primary: 'bg-blue-600 hover:bg-blue-700 focus:ring-blue-500',
secondary: 'bg-gray-200 hover:bg-gray-300 focus:ring-gray-500',
danger: 'bg-red-600 hover:bg-red-700 focus:ring-red-500',
ghost: 'bg-transparent hover:bg-gray-100 focus:ring-gray-500',
};
const buttonSizes = {
sm: 'px-3 py-1.5 text-sm',
md: 'px-4 py-2 text-base',
lg: 'px-5 py-2.5 text-lg',
};
export function Button({
variant = 'primary',
size = 'md',
className = '',
children,
...props
}: ButtonProps) {
return (
<button
className={cn(
'rounded-lg font-medium transition-colors focus:outline-none focus:ring-2',
buttonVariants[variant],
buttonSizes[size],
className
)}
{...props}
>
{children}
</button>
);
}Responsive Variants
interface CardProps {
isFeatured?: boolean;
children: React.ReactNode;
}
export function Card({ isFeatured = false, children }: CardProps) {
return (
<div
className={cn(
'rounded-lg shadow-md p-6 transition-all',
'bg-white hover:shadow-lg',
// Responsive padding
'sm:p-4 md:p-6 lg:p-8',
// Featured highlight
isFeatured && 'ring-2 ring-blue-500 border-2 border-blue-500'
)}
>
{children}
</div>
);
}---
Part 6: State Management Patterns
Context API for Global State
// AppContext.tsx
interface AppState {
user: User | null;
loading: boolean;
login: (credentials: LoginCredentials) => Promise<void>;
logout: () => void;
}
const AppContext = createContext<AppState | null>(null);
export function AppProvider({ children }: { children: React.ReactNode }) {
const [user, setUser] = useState<User | null>(null);
const [loading, setLoading] = useState(false);
const login = async (credentials: LoginCredentials) => {
setLoading(true);
try {
const userData = await api.login(credentials);
setUser(userData);
} finally {
setLoading(false);
}
};
const logout = () => {
api.logout();
setUser(null);
};
return (
<AppContext.Provider value={{ user, loading, login, logout }}>
{children}
</AppContext.Provider>
);
}
export function useApp() {
const context = useContext(AppContext);
if (!context) throw new Error('useApp must be used within AppProvider');
return context;
}Using React Query (TanStack Query)
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
// Fetch data
function UserProfile({ userId }: { userId: string }) {
const { data: user, isLoading, error } = useQuery({
queryKey: ['user', userId],
queryFn: () => api.getUser(userId),
staleTime: 5 * 60 * 1000, // 5 minutes
});
if (isLoading) return <LoadingSpinner />;
if (error) return <ErrorAlert message={error.message} />;
return (
<div>
<h1>{user?.name}</h1>
<p>{user?.email}</p>
</div>
);
}
// Mutate data
function UpdateUserForm({ user: initialUser }: { user: User }) {
const queryClient = useQueryClient();
const mutation = useMutation({
mutationFn: api.updateUser,
onSuccess: () => {
// Invalidate and refetch
queryClient.invalidateQueries({ queryKey: ['user', initialUser.id] });
},
});
const handleSubmit = (data: Partial<User>) => {
mutation.mutate({ ...initialUser, ...data });
};
return (
<form onSubmit={(e) => { e.preventDefault(); handleSubmit(/* data */); }}>
{/* Form fields */}
</form>
);
}---
Part 7: Performance Optimization
Code Splitting with React.lazy
import { lazy, Suspense } from 'react';
// Lazy load components
const Dashboard = lazy(() => import('./pages/Dashboard'));
const Reports = lazy(() => import('./pages/Reports'));
const Settings = lazy(() => import('./pages/Settings'));
function App() {
return (
<Suspense fallback={<LoadingSpinner />}>
<Routes>
<Route path="/dashboard" element={<Dashboard />} />
<Route path="/reports" element={<Reports />} />
<Route path="/settings" element={<Settings />} />
</Routes>
</Suspense>
);
}Virtual Scrolling
import { useVirtualizer } from '@tanstack/react-virtual';
function VirtualList({ items }: { items: Item[] }) {
const parentRef = useRef<HTMLDivElement>(null);
const virtualizer = useVirtualizer({
count: items.length,
getScrollElement: () => parentRef.current,
estimateSize: () => 50, // Estimated item height
overscan: 5, // Render extra items beyond viewport
});
return (
<div ref={parentRef} className="h-96 overflow-auto">
<div
style={{
height: `${virtualizer.getTotalSize()}px`,
position: 'relative',
}}
>
{virtualizer.getVirtualItems().map((virtualItem) => {
const item = items[virtualItem.index];
return (
<div
key={virtualItem.key}
style={{
position: 'absolute',
top: 0,
left: 0,
width: '100%',
height: `${virtualItem.size}px`,
transform: `translateY(${virtualItem.start}px)`,
}}
>
{/*
...existing code...
*/}
</div>
);
})}
</div>
</div>
);
}---
Part 8: Testing with React Testing Library
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
import { expect } from 'vitest';
import userEvent from '@testing-library/user-event';
describe('Button Component', () => {
it('renders children correctly', () => {
render(<Button>Click Me</Button>);
expect(screen.getByText('Click Me')).toBeInTheDocument();
});
it('calls onClick handler when clicked', async () => {
const user = userEvent.setup();
const handleClick = vi.fn();
render(<Button onClick={handleClick}>Click Me</Button>);
await user.click(screen.getByText('Click Me'));
expect(handleClick).toHaveBeenCalledTimes(1);
});
it('is disabled when disabled prop is true', () => {
render(<Button disabled>Cannot Click</Button>);
expect(screen.getByRole('button')).toBeDisabled();
});
});
describe('UseFetch Hook', () => {
it('fetches data and returns it', async () => {
const { result } = renderHook(() => useFetch('/api/test'));
await waitFor(() => {
expect(result.current.data).toBeDefined();
expect(result.current.loading).toBe(false);
});
});
});---
Modern Component and Testing Examples
Server Components
export async function OrdersPanel() {
const orders = await getOrders();
return <OrdersTable orders={orders} />;
}Error Boundaries
import { ErrorBoundary } from 'react-error-boundary';
<ErrorBoundary fallbackRender={() => <p>Could not load dashboard.</p>}>
<Dashboard />
</ErrorBoundary>Accessibility Testing Tools
import { axe } from 'jest-axe';
test('dialog passes axe checks', async () => {
const { container } = render(<AccountDialog open />);
expect(await axe(container)).toHaveNoViolations();
});React Development Best Practices
Components
- [ ] Use functional components with hooks
- [ ] Keep components small and focused
- [ ] Use TypeScript for type safety
- [ ] Implement proper error boundaries
- [ ] Add loading and error states
Hooks
- [ ] Follow rules of hooks (only call at top level)
- [ ] Use custom hooks for reusable stateful logic
- [ ] Memoize expensive computations and callbacks
- [ ] Clean up side effects properly
Performance
- [ ] Implement code splitting for large bundles
- [ ] Use virtual scrolling for long lists
- [ ] Debounce/throttle user input
- [ ] Lazy load images and resources
Accessibility
- [ ] Use semantic HTML elements
- [ ] Ensure keyboard navigation works
- [ ] Add ARIA labels where needed
- [ ] Support screen readers properly
- [ ] Test with accessibility tools
---
Common Pitfalls
- Fetching everything in client effects by default: Server-first data loading is usually simpler, faster, and easier to cache.
- Overusing memoization without a profiler: Manual memoization adds complexity and can hide stale-prop bugs.
- Skipping accessible names and keyboard flows: Components can appear done while still failing real user interaction paths.
References & Resources
Documentation
- Hooks Reference — All 17 React hooks including React 19 new hooks (use, useFormStatus, useOptimistic)
- Patterns Catalog — 12 React component patterns with TypeScript examples
Scripts
- Component Generator — PowerShell component scaffolder with types: functional, page, layout, context
Examples
- Custom Hooks Gallery — 12 production-ready custom hooks with full implementations and tests
---
<!-- PORTABILITY:START -->
Cross-Client Portability
This skill is written to stay usable across GitHub Copilot, Claude Code, Codex, and Gemini CLI.
- GitHub Copilot: keep the folder in a Copilot-visible skill or plugin path, or wrap the workflow as project instructions if the host does not support portable skill folders directly.
- Claude Code: keep the folder in a local skills directory or a compatible plugin or marketplace source.
- Codex: install or sync the folder into
$CODEX_HOME/skills/<skill-name>and restart Codex after major changes. - Gemini CLI: this repository generates a project command named
/skills:react-developmentfrom this skill. Rebuild commands withpython scripts/export-gemini-skill.py react-developmentand then run/commands reloadinside Gemini CLI.
<!-- PORTABILITY:END -->
<!-- MCP:START -->
MCP Availability And Fallback
Preferred MCP Server: None required
- Fallback prompt: "Use the React Development skill without MCP. Rely on the local
SKILL.md, bundled references or scripts, and manual verification. Show the exact commands, evidence, and final checks you used before concluding." - If the current host does not expose a matching server, use the bundled references, scripts, native toolchain, and manual workflow already described in this skill.
- Treat direct local verification, rendered output, logs, tests, or screenshots as the fallback evidence path before completion.
<!-- MCP:END -->
Related Skills
- javascript-development: Use it when the workflow also needs modern JavaScript and TypeScript application code.
- nextjs-development: Use it when the workflow also needs Next.js App Router and server-first React patterns.
- web-testing: Use it when the workflow also needs browser and end-to-end testing evidence.
- code-quality: Use it when the workflow also needs two-stage review (spec compliance first, then code quality), maintainability, and refactoring guidance.
Changelog
[2026-04-25] - Version 1.2 Verification Protocol Refresh
Added
- Added a
Verification Protocolsection with skill-specific pass/fail checks, one pressure-test scenario, and a measurable success metric. - Added guidance to leverage native parallel subagent dispatch and 200k+ context windows where available.
- Added or referenced the shared Component Review Rubric for frontend component review.
Changed
- Updated
SKILL.mdfrontmatter toversion: "1.2"andlast_updated: 2026-04-25. - Reframed activation guidance toward symptom -> action triggers and standardized two-stage review wording where applicable.
[2026-04-24] - Version 1.1 Refresh
Changed
- Updated the SKILL frontmatter version to
1.1for the 2026-04-24 catalog refresh.
[2026-04-24] - Skill Refresh
Changed
- Standardized the SKILL frontmatter with version metadata, last-updated date, tags, and a concise catalog description.
- Reformatted the portability and MCP guidance with a preferred server line, a copy-paste fallback prompt, and consistent bullet lists.
- Added a catalog-standard Anti-Patterns section and refreshed the Related Skills links at the end of the skill.
- Added current-version targeting, a before-and-after example, Common Pitfalls, and modern examples for Server Components, Error Boundaries, and accessibility testing tools.
[2026-04-24] - Catalog Audit Cleanup
Fixed
- Removed obsolete standalone Skill Paths guidance that duplicated the generated portability section.
All notable changes to this skill will be documented in this file.
[2026-04-04] - Cross-Client Portability Refresh
Changed
- Added a standard portability note covering GitHub Copilot, Claude Code, Codex, and Gemini CLI.
- Clarified that the core workflow does not require a dedicated MCP server and can run with local tools alone.
Tested
- Validated
SKILL.mdfrontmatter, portability sections, and Gemini export readiness withpython scripts/validate-skills.py.
[2026-03-09] - Workspace Modernization
Added
- Added a 2026-03-09 maintenance entry after reviewing the skill; no new skill-body updates were needed in this pass.
[2026-02-28] — Description Rewrite & Cross-References
Changed
- Rewrote skill description to ~200 characters with clear, specific activation keywords
- Improved keyword specificity to reduce overlap with related skills
Added
## Related Skillscross-reference table with 2-4 related skills and "Use When" guidance
Custom React Hooks Gallery
Production-ready custom hooks with full TypeScript, usage examples, and test patterns.
---
Table of Contents
- useDebounce
- useLocalStorage
- useFetch
- useMediaQuery
- useOnClickOutside
- useKeyPress
- usePrevious
- useIntersectionObserver
- useClipboard
- useToggle
- useEventListener
- usePagination
---
useDebounce
Debounce a rapidly changing value. Useful for search inputs, resize handlers, and API calls.
Implementation
import { useState, useEffect } from 'react';
export function useDebounce<T>(value: T, delay: number = 300): T {
const [debouncedValue, setDebouncedValue] = useState<T>(value);
useEffect(() => {
const timer = setTimeout(() => setDebouncedValue(value), delay);
return () => clearTimeout(timer);
}, [value, delay]);
return debouncedValue;
}Usage
function SearchInput() {
const [query, setQuery] = useState('');
const debouncedQuery = useDebounce(query, 500);
useEffect(() => {
if (debouncedQuery) {
searchApi(debouncedQuery).then(setResults);
}
}, [debouncedQuery]);
return <input value={query} onChange={(e) => setQuery(e.target.value)} />;
}Test
import { renderHook, act } from '@testing-library/react';
import { describe, it, expect, vi } from 'vitest';
import { useDebounce } from './useDebounce';
describe('useDebounce', () => {
beforeEach(() => { vi.useFakeTimers(); });
afterEach(() => { vi.useRealTimers(); });
it('returns initial value immediately', () => {
const { result } = renderHook(() => useDebounce('hello', 300));
expect(result.current).toBe('hello');
});
it('debounces value changes', () => {
const { result, rerender } = renderHook(
({ value }) => useDebounce(value, 300),
{ initialProps: { value: 'a' } }
);
rerender({ value: 'ab' });
expect(result.current).toBe('a');
act(() => { vi.advanceTimersByTime(300); });
expect(result.current).toBe('ab');
});
it('resets timer on rapid changes', () => {
const { result, rerender } = renderHook(
({ value }) => useDebounce(value, 300),
{ initialProps: { value: 'a' } }
);
rerender({ value: 'ab' });
act(() => { vi.advanceTimersByTime(200); });
rerender({ value: 'abc' });
act(() => { vi.advanceTimersByTime(200); });
expect(result.current).toBe('a');
act(() => { vi.advanceTimersByTime(100); });
expect(result.current).toBe('abc');
});
});---
useLocalStorage
Persist state in localStorage with type safety and SSR compatibility.
Types
type SetValue<T> = T | ((prevValue: T) => T);Implementation
import { useState, useCallback, useEffect } from 'react';
export function useLocalStorage<T>(
key: string,
initialValue: T
): [T, (value: SetValue<T>) => void, () => void] {
const readValue = useCallback((): T => {
if (typeof window === 'undefined') return initialValue;
try {
const item = window.localStorage.getItem(key);
return item ? (JSON.parse(item) as T) : initialValue;
} catch {
return initialValue;
}
}, [key, initialValue]);
const [storedValue, setStoredValue] = useState<T>(readValue);
const setValue = useCallback(
(value: SetValue<T>) => {
try {
const newValue = value instanceof Function ? value(storedValue) : value;
window.localStorage.setItem(key, JSON.stringify(newValue));
setStoredValue(newValue);
window.dispatchEvent(new StorageEvent('storage', { key }));
} catch (error) {
console.warn(`Error setting localStorage key "${key}":`, error);
}
},
[key, storedValue]
);
const removeValue = useCallback(() => {
try {
window.localStorage.removeItem(key);
setStoredValue(initialValue);
} catch (error) {
console.warn(`Error removing localStorage key "${key}":`, error);
}
}, [key, initialValue]);
// Sync across tabs
useEffect(() => {
function handleStorageChange(e: StorageEvent) {
if (e.key === key) setStoredValue(readValue());
}
window.addEventListener('storage', handleStorageChange);
return () => window.removeEventListener('storage', handleStorageChange);
}, [key, readValue]);
return [storedValue, setValue, removeValue];
}Usage
function ThemeToggle() {
const [theme, setTheme] = useLocalStorage<'light' | 'dark'>('theme', 'light');
return (
<button onClick={() => setTheme((prev) => (prev === 'light' ? 'dark' : 'light'))}>
Current: {theme}
</button>
);
}Test
import { renderHook, act } from '@testing-library/react';
import { describe, it, expect, beforeEach } from 'vitest';
import { useLocalStorage } from './useLocalStorage';
describe('useLocalStorage', () => {
beforeEach(() => { localStorage.clear(); });
it('returns initial value when key does not exist', () => {
const { result } = renderHook(() => useLocalStorage('test', 'default'));
expect(result.current[0]).toBe('default');
});
it('persists value to localStorage', () => {
const { result } = renderHook(() => useLocalStorage('test', 'default'));
act(() => { result.current[1]('updated'); });
expect(result.current[0]).toBe('updated');
expect(JSON.parse(localStorage.getItem('test')!)).toBe('updated');
});
it('supports functional updates', () => {
const { result } = renderHook(() => useLocalStorage('count', 0));
act(() => { result.current[1]((prev) => prev + 1); });
expect(result.current[0]).toBe(1);
});
it('removes value', () => {
const { result } = renderHook(() => useLocalStorage('test', 'default'));
act(() => { result.current[1]('value'); });
act(() => { result.current[2](); });
expect(result.current[0]).toBe('default');
expect(localStorage.getItem('test')).toBeNull();
});
});---
useFetch
Data fetching with AbortController, loading/error states, and refetch capability.
Types
interface FetchState<T> {
data: T | null;
error: Error | null;
isLoading: boolean;
}
interface UseFetchReturn<T> extends FetchState<T> {
refetch: () => void;
}Implementation
import { useState, useEffect, useCallback, useRef } from 'react';
export function useFetch<T>(url: string | null, options?: RequestInit): UseFetchReturn<T> {
const [state, setState] = useState<FetchState<T>>({
data: null,
error: null,
isLoading: !!url,
});
const abortControllerRef = useRef<AbortController | null>(null);
const [fetchCount, setFetchCount] = useState(0);
const refetch = useCallback(() => setFetchCount((c) => c + 1), []);
useEffect(() => {
if (!url) {
setState({ data: null, error: null, isLoading: false });
return;
}
abortControllerRef.current?.abort();
const controller = new AbortController();
abortControllerRef.current = controller;
setState((prev) => ({ ...prev, isLoading: true, error: null }));
fetch(url, { ...options, signal: controller.signal })
.then((res) => {
if (!res.ok) throw new Error(`HTTP ${res.status}: ${res.statusText}`);
return res.json() as Promise<T>;
})
.then((data) => {
if (!controller.signal.aborted) {
setState({ data, error: null, isLoading: false });
}
})
.catch((error) => {
if (!controller.signal.aborted) {
setState({ data: null, error: error as Error, isLoading: false });
}
});
return () => controller.abort();
}, [url, fetchCount]); // eslint-disable-line react-hooks/exhaustive-deps
return { ...state, refetch };
}Usage
function UserProfile({ userId }: { userId: string }) {
const { data: user, isLoading, error, refetch } = useFetch<User>(
`/api/users/${userId}`
);
if (isLoading) return <Spinner />;
if (error) return <ErrorBanner message={error.message} onRetry={refetch} />;
if (!user) return null;
return <h1>{user.name}</h1>;
}Test
import { renderHook, waitFor } from '@testing-library/react';
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { useFetch } from './useFetch';
describe('useFetch', () => {
beforeEach(() => { vi.restoreAllMocks(); });
it('fetches data successfully', async () => {
vi.spyOn(global, 'fetch').mockResolvedValueOnce({
ok: true,
json: () => Promise.resolve({ id: 1, name: 'Test' }),
} as Response);
const { result } = renderHook(() => useFetch<{ id: number; name: string }>('/api/test'));
expect(result.current.isLoading).toBe(true);
await waitFor(() => expect(result.current.isLoading).toBe(false));
expect(result.current.data).toEqual({ id: 1, name: 'Test' });
expect(result.current.error).toBeNull();
});
it('handles fetch errors', async () => {
vi.spyOn(global, 'fetch').mockResolvedValueOnce({
ok: false,
status: 404,
statusText: 'Not Found',
} as Response);
const { result } = renderHook(() => useFetch('/api/missing'));
await waitFor(() => expect(result.current.isLoading).toBe(false));
expect(result.current.error?.message).toContain('404');
});
it('returns idle state for null URL', () => {
const { result } = renderHook(() => useFetch(null));
expect(result.current.isLoading).toBe(false);
expect(result.current.data).toBeNull();
});
});---
useMediaQuery
Reactive CSS media query matching with SSR safety.
Implementation
import { useState, useEffect } from 'react';
export function useMediaQuery(query: string): boolean {
const [matches, setMatches] = useState<boolean>(() => {
if (typeof window === 'undefined') return false;
return window.matchMedia(query).matches;
});
useEffect(() => {
const mediaQuery = window.matchMedia(query);
setMatches(mediaQuery.matches);
function handleChange(e: MediaQueryListEvent) {
setMatches(e.matches);
}
mediaQuery.addEventListener('change', handleChange);
return () => mediaQuery.removeEventListener('change', handleChange);
}, [query]);
return matches;
}Usage
function ResponsiveLayout() {
const isMobile = useMediaQuery('(max-width: 768px)');
const prefersDark = useMediaQuery('(prefers-color-scheme: dark)');
return (
<div className={prefersDark ? 'dark' : ''}>
{isMobile ? <MobileNav /> : <DesktopNav />}
</div>
);
}Test
import { renderHook, act } from '@testing-library/react';
import { describe, it, expect, vi } from 'vitest';
import { useMediaQuery } from './useMediaQuery';
describe('useMediaQuery', () => {
it('returns initial match state', () => {
const listeners: Array<(e: { matches: boolean }) => void> = [];
vi.spyOn(window, 'matchMedia').mockReturnValue({
matches: true,
addEventListener: (_: string, fn: any) => listeners.push(fn),
removeEventListener: vi.fn(),
} as any);
const { result } = renderHook(() => useMediaQuery('(min-width: 768px)'));
expect(result.current).toBe(true);
});
it('updates when media query changes', () => {
const listeners: Array<(e: { matches: boolean }) => void> = [];
vi.spyOn(window, 'matchMedia').mockReturnValue({
matches: false,
addEventListener: (_: string, fn: any) => listeners.push(fn),
removeEventListener: vi.fn(),
} as any);
const { result } = renderHook(() => useMediaQuery('(min-width: 768px)'));
expect(result.current).toBe(false);
act(() => { listeners.forEach((fn) => fn({ matches: true })); });
expect(result.current).toBe(true);
});
});---
useOnClickOutside
Detect clicks outside a referenced element. Useful for dropdowns, popovers, and modals.
Implementation
import { useEffect, type RefObject } from 'react';
type EventType = MouseEvent | TouchEvent;
export function useOnClickOutside<T extends HTMLElement>(
ref: RefObject<T | null>,
handler: (event: EventType) => void
): void {
useEffect(() => {
function listener(event: EventType) {
const el = ref.current;
if (!el || el.contains(event.target as Node)) return;
handler(event);
}
document.addEventListener('mousedown', listener);
document.addEventListener('touchstart', listener);
return () => {
document.removeEventListener('mousedown', listener);
document.removeEventListener('touchstart', listener);
};
}, [ref, handler]);
}Usage
function Dropdown() {
const [isOpen, setIsOpen] = useState(false);
const dropdownRef = useRef<HTMLDivElement>(null);
useOnClickOutside(dropdownRef, () => setIsOpen(false));
return (
<div ref={dropdownRef}>
<button onClick={() => setIsOpen(!isOpen)}>Toggle</button>
{isOpen && <div className="absolute mt-2 rounded border bg-white p-4 shadow">Menu</div>}
</div>
);
}Test
import { render, screen, fireEvent } from '@testing-library/react';
import { useRef, useState } from 'react';
import { describe, it, expect, vi } from 'vitest';
import { useOnClickOutside } from './useOnClickOutside';
function TestComponent({ onClickOutside }: { onClickOutside: () => void }) {
const ref = useRef<HTMLDivElement>(null);
useOnClickOutside(ref, onClickOutside);
return (
<div>
<div ref={ref} data-testid="inside">Inside</div>
<div data-testid="outside">Outside</div>
</div>
);
}
describe('useOnClickOutside', () => {
it('calls handler on outside click', () => {
const handler = vi.fn();
render(<TestComponent onClickOutside={handler} />);
fireEvent.mouseDown(screen.getByTestId('outside'));
expect(handler).toHaveBeenCalledTimes(1);
});
it('does not call handler on inside click', () => {
const handler = vi.fn();
render(<TestComponent onClickOutside={handler} />);
fireEvent.mouseDown(screen.getByTestId('inside'));
expect(handler).not.toHaveBeenCalled();
});
});---
useKeyPress
Detect specific key presses with modifier support.
Implementation
import { useState, useEffect, useCallback } from 'react';
interface KeyPressOptions {
target?: EventTarget;
ctrlKey?: boolean;
shiftKey?: boolean;
altKey?: boolean;
metaKey?: boolean;
}
export function useKeyPress(targetKey: string, options: KeyPressOptions = {}): boolean {
const [isPressed, setIsPressed] = useState(false);
const { target = window, ctrlKey, shiftKey, altKey, metaKey } = options;
const matchesModifiers = useCallback(
(event: KeyboardEvent): boolean => {
if (ctrlKey !== undefined && event.ctrlKey !== ctrlKey) return false;
if (shiftKey !== undefined && event.shiftKey !== shiftKey) return false;
if (altKey !== undefined && event.altKey !== altKey) return false;
if (metaKey !== undefined && event.metaKey !== metaKey) return false;
return true;
},
[ctrlKey, shiftKey, altKey, metaKey]
);
useEffect(() => {
function handleDown(e: Event) {
const event = e as KeyboardEvent;
if (event.key === targetKey && matchesModifiers(event)) {
setIsPressed(true);
}
}
function handleUp(e: Event) {
const event = e as KeyboardEvent;
if (event.key === targetKey) {
setIsPressed(false);
}
}
target.addEventListener('keydown', handleDown);
target.addEventListener('keyup', handleUp);
return () => {
target.removeEventListener('keydown', handleDown);
target.removeEventListener('keyup', handleUp);
};
}, [targetKey, target, matchesModifiers]);
return isPressed;
}Usage
function ShortcutDemo() {
const isEscPressed = useKeyPress('Escape');
const isSavePressed = useKeyPress('s', { ctrlKey: true });
useEffect(() => {
if (isSavePressed) {
saveDocument();
}
}, [isSavePressed]);
return <div>{isEscPressed && <p>Escape pressed!</p>}</div>;
}Test
import { renderHook, act } from '@testing-library/react';
import { describe, it, expect } from 'vitest';
import { useKeyPress } from './useKeyPress';
describe('useKeyPress', () => {
it('detects key press and release', () => {
const { result } = renderHook(() => useKeyPress('Enter'));
expect(result.current).toBe(false);
act(() => {
window.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter' }));
});
expect(result.current).toBe(true);
act(() => {
window.dispatchEvent(new KeyboardEvent('keyup', { key: 'Enter' }));
});
expect(result.current).toBe(false);
});
it('respects modifier keys', () => {
const { result } = renderHook(() => useKeyPress('s', { ctrlKey: true }));
act(() => {
window.dispatchEvent(new KeyboardEvent('keydown', { key: 's', ctrlKey: false }));
});
expect(result.current).toBe(false);
act(() => {
window.dispatchEvent(new KeyboardEvent('keydown', { key: 's', ctrlKey: true }));
});
expect(result.current).toBe(true);
});
});---
usePrevious
Track the previous value of a prop or state across renders.
Implementation
import { useRef, useEffect } from 'react';
export function usePrevious<T>(value: T): T | undefined {
const ref = useRef<T | undefined>(undefined);
useEffect(() => {
ref.current = value;
}, [value]);
return ref.current;
}Usage
function Counter() {
const [count, setCount] = useState(0);
const prevCount = usePrevious(count);
return (
<div>
<p>Current: {count}, Previous: {prevCount ?? 'N/A'}</p>
<button onClick={() => setCount((c) => c + 1)}>Increment</button>
</div>
);
}Test
import { renderHook } from '@testing-library/react';
import { describe, it, expect } from 'vitest';
import { usePrevious } from './usePrevious';
describe('usePrevious', () => {
it('returns undefined on first render', () => {
const { result } = renderHook(() => usePrevious(0));
expect(result.current).toBeUndefined();
});
it('returns previous value after update', () => {
const { result, rerender } = renderHook(({ value }) => usePrevious(value), {
initialProps: { value: 1 },
});
expect(result.current).toBeUndefined();
rerender({ value: 2 });
expect(result.current).toBe(1);
rerender({ value: 3 });
expect(result.current).toBe(2);
});
});---
useIntersectionObserver
Observe element visibility using the Intersection Observer API. Useful for lazy loading, infinite scroll, and animations.
Types
interface UseIntersectionObserverOptions extends IntersectionObserverInit {
freezeOnceVisible?: boolean;
}Implementation
import { useState, useEffect, useRef, type RefObject } from 'react';
export function useIntersectionObserver<T extends HTMLElement>(
options: UseIntersectionObserverOptions = {}
): [RefObject<T | null>, IntersectionObserverEntry | null] {
const { threshold = 0, root = null, rootMargin = '0px', freezeOnceVisible = false } = options;
const ref = useRef<T | null>(null);
const [entry, setEntry] = useState<IntersectionObserverEntry | null>(null);
const frozen = entry?.isIntersecting && freezeOnceVisible;
useEffect(() => {
const node = ref.current;
if (!node || frozen) return;
const observer = new IntersectionObserver(
([entry]) => setEntry(entry),
{ threshold, root, rootMargin }
);
observer.observe(node);
return () => observer.disconnect();
}, [threshold, root, rootMargin, frozen]);
return [ref, entry];
}Usage
function LazyImage({ src, alt }: { src: string; alt: string }) {
const [ref, entry] = useIntersectionObserver<HTMLDivElement>({
threshold: 0.1,
freezeOnceVisible: true,
});
const isVisible = entry?.isIntersecting ?? false;
return (
<div ref={ref} className="min-h-[200px]">
{isVisible ? (
<img src={src} alt={alt} className="h-auto w-full" />
) : (
<div className="h-[200px] animate-pulse bg-gray-200" />
)}
</div>
);
}
// Infinite scroll trigger
function InfiniteList({ loadMore }: { loadMore: () => void }) {
const [ref, entry] = useIntersectionObserver<HTMLDivElement>();
useEffect(() => {
if (entry?.isIntersecting) loadMore();
}, [entry?.isIntersecting, loadMore]);
return <div ref={ref} className="h-4" />;
}Test
import { renderHook } from '@testing-library/react';
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { useIntersectionObserver } from './useIntersectionObserver';
describe('useIntersectionObserver', () => {
const observeMock = vi.fn();
const disconnectMock = vi.fn();
beforeEach(() => {
vi.stubGlobal('IntersectionObserver', vi.fn((cb) => ({
observe: observeMock,
disconnect: disconnectMock,
unobserve: vi.fn(),
})));
});
it('returns a ref and null entry initially', () => {
const { result } = renderHook(() => useIntersectionObserver());
expect(result.current[0]).toBeDefined();
expect(result.current[1]).toBeNull();
});
it('disconnects on unmount', () => {
const { unmount } = renderHook(() => useIntersectionObserver());
unmount();
expect(disconnectMock).toHaveBeenCalled();
});
});---
useClipboard
Copy text to clipboard with a temporary "copied" state.
Types
interface UseClipboardReturn {
copy: (text: string) => Promise<void>;
isCopied: boolean;
error: Error | null;
}Implementation
import { useState, useCallback, useRef } from 'react';
export function useClipboard(resetDelay: number = 2000): UseClipboardReturn {
const [isCopied, setIsCopied] = useState(false);
const [error, setError] = useState<Error | null>(null);
const timeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const copy = useCallback(
async (text: string) => {
try {
await navigator.clipboard.writeText(text);
setIsCopied(true);
setError(null);
if (timeoutRef.current) clearTimeout(timeoutRef.current);
timeoutRef.current = setTimeout(() => setIsCopied(false), resetDelay);
} catch (err) {
setError(err instanceof Error ? err : new Error('Failed to copy'));
setIsCopied(false);
}
},
[resetDelay]
);
return { copy, isCopied, error };
}Usage
function CopyButton({ text }: { text: string }) {
const { copy, isCopied } = useClipboard();
return (
<button
onClick={() => copy(text)}
className="rounded border px-3 py-1 text-sm"
>
{isCopied ? 'Copied!' : 'Copy'}
</button>
);
}Test
import { renderHook, act } from '@testing-library/react';
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { useClipboard } from './useClipboard';
describe('useClipboard', () => {
beforeEach(() => {
Object.assign(navigator, {
clipboard: { writeText: vi.fn().mockResolvedValue(undefined) },
});
vi.useFakeTimers();
});
afterEach(() => { vi.useRealTimers(); });
it('copies text and sets isCopied', async () => {
const { result } = renderHook(() => useClipboard());
await act(async () => { await result.current.copy('hello'); });
expect(result.current.isCopied).toBe(true);
expect(navigator.clipboard.writeText).toHaveBeenCalledWith('hello');
});
it('resets isCopied after delay', async () => {
const { result } = renderHook(() => useClipboard(1000));
await act(async () => { await result.current.copy('hello'); });
expect(result.current.isCopied).toBe(true);
act(() => { vi.advanceTimersByTime(1000); });
expect(result.current.isCopied).toBe(false);
});
it('handles clipboard errors', async () => {
(navigator.clipboard.writeText as any).mockRejectedValueOnce(new Error('Denied'));
const { result } = renderHook(() => useClipboard());
await act(async () => { await result.current.copy('fail'); });
expect(result.current.isCopied).toBe(false);
expect(result.current.error?.message).toBe('Denied');
});
});---
useToggle
Boolean toggle state with explicit set/reset.
Implementation
import { useState, useCallback } from 'react';
export function useToggle(
initialValue: boolean = false
): [boolean, () => void, (value: boolean) => void] {
const [value, setValue] = useState(initialValue);
const toggle = useCallback(() => setValue((v) => !v), []);
const set = useCallback((newValue: boolean) => setValue(newValue), []);
return [value, toggle, set];
}Usage
function Disclosure() {
const [isOpen, toggle] = useToggle(false);
return (
<div>
<button onClick={toggle} aria-expanded={isOpen}>
{isOpen ? 'Hide' : 'Show'} Details
</button>
{isOpen && <p>Hidden content revealed.</p>}
</div>
);
}Test
import { renderHook, act } from '@testing-library/react';
import { describe, it, expect } from 'vitest';
import { useToggle } from './useToggle';
describe('useToggle', () => {
it('initializes with default value', () => {
const { result } = renderHook(() => useToggle());
expect(result.current[0]).toBe(false);
});
it('toggles value', () => {
const { result } = renderHook(() => useToggle(false));
act(() => { result.current[1](); });
expect(result.current[0]).toBe(true);
act(() => { result.current[1](); });
expect(result.current[0]).toBe(false);
});
it('sets explicit value', () => {
const { result } = renderHook(() => useToggle(false));
act(() => { result.current[2](true); });
expect(result.current[0]).toBe(true);
act(() => { result.current[2](true); });
expect(result.current[0]).toBe(true);
});
});---
useEventListener
Type-safe wrapper for addEventListener with automatic cleanup.
Implementation
import { useEffect, useRef } from 'react';
export function useEventListener<K extends keyof WindowEventMap>(
eventName: K,
handler: (event: WindowEventMap[K]) => void,
element?: undefined,
options?: boolean | AddEventListenerOptions
): void;
export function useEventListener<
K extends keyof HTMLElementEventMap,
T extends HTMLElement
>(
eventName: K,
handler: (event: HTMLElementEventMap[K]) => void,
element: React.RefObject<T>,
options?: boolean | AddEventListenerOptions
): void;
export function useEventListener(
eventName: string,
handler: (event: Event) => void,
element?: React.RefObject<HTMLElement>,
options?: boolean | AddEventListenerOptions
): void {
const savedHandler = useRef(handler);
useEffect(() => {
savedHandler.current = handler;
}, [handler]);
useEffect(() => {
const target = element?.current ?? window;
function eventListener(event: Event) {
savedHandler.current(event);
}
target.addEventListener(eventName, eventListener, options);
return () => target.removeEventListener(eventName, eventListener, options);
}, [eventName, element, options]);
}Usage
function ScrollTracker() {
const [scrollY, setScrollY] = useState(0);
useEventListener('scroll', () => {
setScrollY(window.scrollY);
});
return <div className="fixed top-0 right-0 p-2 text-xs">Scroll: {scrollY}px</div>;
}
// On a specific element
function HoverCard() {
const cardRef = useRef<HTMLDivElement>(null);
const [isHovered, setIsHovered] = useState(false);
useEventListener('mouseenter', () => setIsHovered(true), cardRef);
useEventListener('mouseleave', () => setIsHovered(false), cardRef);
return (
<div ref={cardRef} className={isHovered ? 'shadow-lg scale-105' : 'shadow'}>
Hover me
</div>
);
}Test
import { renderHook } from '@testing-library/react';
import { describe, it, expect, vi } from 'vitest';
import { useEventListener } from './useEventListener';
describe('useEventListener', () => {
it('adds and removes window event listener', () => {
const addSpy = vi.spyOn(window, 'addEventListener');
const removeSpy = vi.spyOn(window, 'removeEventListener');
const handler = vi.fn();
const { unmount } = renderHook(() => useEventListener('resize', handler));
expect(addSpy).toHaveBeenCalledWith('resize', expect.any(Function), undefined);
unmount();
expect(removeSpy).toHaveBeenCalledWith('resize', expect.any(Function), undefined);
addSpy.mockRestore();
removeSpy.mockRestore();
});
it('uses latest handler without re-attaching listener', () => {
const addSpy = vi.spyOn(window, 'addEventListener');
const handler1 = vi.fn();
const handler2 = vi.fn();
const { rerender } = renderHook(
({ handler }) => useEventListener('click', handler),
{ initialProps: { handler: handler1 } }
);
const callCount = addSpy.mock.calls.length;
rerender({ handler: handler2 });
// Listener should not be re-attached
expect(addSpy.mock.calls.length).toBe(callCount);
addSpy.mockRestore();
});
});---
usePagination
Client-side pagination logic with page calculations and navigation helpers.
Types
interface UsePaginationOptions {
totalItems: number;
itemsPerPage?: number;
initialPage?: number;
siblingCount?: number;
}
interface UsePaginationReturn {
currentPage: number;
totalPages: number;
startIndex: number;
endIndex: number;
hasPrev: boolean;
hasNext: boolean;
pages: (number | 'ellipsis')[];
goToPage: (page: number) => void;
nextPage: () => void;
prevPage: () => void;
}Implementation
import { useState, useMemo, useCallback } from 'react';
function generatePageRange(
currentPage: number,
totalPages: number,
siblingCount: number
): (number | 'ellipsis')[] {
const totalSlots = siblingCount * 2 + 5; // siblings + first + last + current + 2 ellipses
if (totalPages <= totalSlots) {
return Array.from({ length: totalPages }, (_, i) => i + 1);
}
const leftSiblingIndex = Math.max(currentPage - siblingCount, 1);
const rightSiblingIndex = Math.min(currentPage + siblingCount, totalPages);
const showLeftEllipsis = leftSiblingIndex > 2;
const showRightEllipsis = rightSiblingIndex < totalPages - 1;
if (!showLeftEllipsis && showRightEllipsis) {
const leftRange = Array.from({ length: 3 + 2 * siblingCount }, (_, i) => i + 1);
return [...leftRange, 'ellipsis', totalPages];
}
if (showLeftEllipsis && !showRightEllipsis) {
const rightRange = Array.from(
{ length: 3 + 2 * siblingCount },
(_, i) => totalPages - (3 + 2 * siblingCount) + i + 1
);
return [1, 'ellipsis', ...rightRange];
}
const middleRange = Array.from(
{ length: rightSiblingIndex - leftSiblingIndex + 1 },
(_, i) => leftSiblingIndex + i
);
return [1, 'ellipsis', ...middleRange, 'ellipsis', totalPages];
}
export function usePagination({
totalItems,
itemsPerPage = 10,
initialPage = 1,
siblingCount = 1,
}: UsePaginationOptions): UsePaginationReturn {
const [currentPage, setCurrentPage] = useState(initialPage);
const totalPages = Math.max(1, Math.ceil(totalItems / itemsPerPage));
const safePage = Math.min(Math.max(1, currentPage), totalPages);
if (safePage !== currentPage) setCurrentPage(safePage);
const goToPage = useCallback(
(page: number) => setCurrentPage(Math.max(1, Math.min(page, totalPages))),
[totalPages]
);
const nextPage = useCallback(
() => setCurrentPage((p) => Math.min(p + 1, totalPages)),
[totalPages]
);
const prevPage = useCallback(
() => setCurrentPage((p) => Math.max(p - 1, 1)),
[]
);
const pages = useMemo(
() => generatePageRange(safePage, totalPages, siblingCount),
[safePage, totalPages, siblingCount]
);
return {
currentPage: safePage,
totalPages,
startIndex: (safePage - 1) * itemsPerPage,
endIndex: Math.min(safePage * itemsPerPage, totalItems),
hasPrev: safePage > 1,
hasNext: safePage < totalPages,
pages,
goToPage,
nextPage,
prevPage,
};
}Usage
function PaginatedList<T>({ items, renderItem }: { items: T[]; renderItem: (item: T) => ReactNode }) {
const {
currentPage, totalPages, startIndex, endIndex,
hasPrev, hasNext, pages, goToPage, nextPage, prevPage,
} = usePagination({ totalItems: items.length, itemsPerPage: 10 });
const visibleItems = items.slice(startIndex, endIndex);
return (
<div>
<ul>{visibleItems.map(renderItem)}</ul>
<nav aria-label="Pagination" className="mt-4 flex items-center gap-1">
<button onClick={prevPage} disabled={!hasPrev} className="rounded px-3 py-1 disabled:opacity-50">
Prev
</button>
{pages.map((page, i) =>
page === 'ellipsis' ? (
<span key={`ellipsis-${i}`} className="px-2">...</span>
) : (
<button
key={page}
onClick={() => goToPage(page)}
className={`rounded px-3 py-1 ${page === currentPage ? 'bg-blue-600 text-white' : 'hover:bg-gray-100'}`}
aria-current={page === currentPage ? 'page' : undefined}
>
{page}
</button>
)
)}
<button onClick={nextPage} disabled={!hasNext} className="rounded px-3 py-1 disabled:opacity-50">
Next
</button>
</nav>
<p className="mt-2 text-sm text-gray-500">
Page {currentPage} of {totalPages}
</p>
</div>
);
}Test
import { renderHook, act } from '@testing-library/react';
import { describe, it, expect } from 'vitest';
import { usePagination } from './usePagination';
describe('usePagination', () => {
it('calculates pages correctly', () => {
const { result } = renderHook(() =>
usePagination({ totalItems: 50, itemsPerPage: 10 })
);
expect(result.current.totalPages).toBe(5);
expect(result.current.currentPage).toBe(1);
expect(result.current.startIndex).toBe(0);
expect(result.current.endIndex).toBe(10);
expect(result.current.hasPrev).toBe(false);
expect(result.current.hasNext).toBe(true);
});
it('navigates to next page', () => {
const { result } = renderHook(() =>
usePagination({ totalItems: 50, itemsPerPage: 10 })
);
act(() => { result.current.nextPage(); });
expect(result.current.currentPage).toBe(2);
expect(result.current.startIndex).toBe(10);
expect(result.current.hasPrev).toBe(true);
});
it('does not go below page 1', () => {
const { result } = renderHook(() =>
usePagination({ totalItems: 50, itemsPerPage: 10 })
);
act(() => { result.current.prevPage(); });
expect(result.current.currentPage).toBe(1);
});
it('does not go above total pages', () => {
const { result } = renderHook(() =>
usePagination({ totalItems: 50, itemsPerPage: 10, initialPage: 5 })
);
act(() => { result.current.nextPage(); });
expect(result.current.currentPage).toBe(5);
});
it('navigates to arbitrary page', () => {
const { result } = renderHook(() =>
usePagination({ totalItems: 100, itemsPerPage: 10 })
);
act(() => { result.current.goToPage(7); });
expect(result.current.currentPage).toBe(7);
expect(result.current.startIndex).toBe(60);
expect(result.current.endIndex).toBe(70);
});
it('generates ellipsis in page range', () => {
const { result } = renderHook(() =>
usePagination({ totalItems: 200, itemsPerPage: 10, initialPage: 10 })
);
expect(result.current.pages).toContain('ellipsis');
expect(result.current.pages[0]).toBe(1);
expect(result.current.pages[result.current.pages.length - 1]).toBe(20);
});
});React JSX Patterns with React 19+
Modern JSX patterns and component examples using React 19+ features, TypeScript, and ES6+ syntax.
JSX Fundamentals
Component Structure
import { useState, useEffect } from 'react';
/**
* RecipeCard component with proper typing and hooks usage
* Displays recipe information with interactive elements
*/
export function RecipeCard({ recipe, onLike, onEdit }) {
const [isLiked, setIsLiked] = useState(false);
const [imageError, setImageError] = useState(false);
useEffect(() => {
// Check if recipe is already liked
setIsLiked(checkIfLiked(recipe.id));
}, [recipe.id]);
const handleLike = () => {
setIsLiked(!isLiked);
onLike?.(recipe.id);
};
if (imageError) {
return (
<div className="recipe-card fallback">
<h3>{recipe.title}</h3>
<p>No image available</p>
</div>
);
}
return (
<article className="recipe-card" data-recipe-id={recipe.id}>
<div className="recipe-image">
<img
src={recipe.imageUrl}
alt={recipe.title}
onError={() => setImageError(true)}
loading="lazy"
/>
</div>
<div className="recipe-content">
<div className="recipe-header">
<h2 className="recipe-title">{recipe.title}</h2>
<span className={`badge ${recipe.difficulty}`}>
{recipe.difficulty}
</span>
</div>
<p className="recipe-description">
{recipe.description}
</p>
<div className="recipe-meta">
<span>⏱ {recipe.prepTime} min</span>
<span>⏰ {recipe.cookTime} min</span>
<span>👥 {recipe.servings} servings</span>
</div>
<div className="recipe-actions">
<button
className={`like-button ${isLiked ? 'liked' : ''}`}
onClick={handleLike}
aria-label="Like recipe"
aria-pressed={isLiked}
>
{isLiked ? '❤️' : '🤍'}
</button>
<button
className="edit-button"
onClick={() => onEdit?.(recipe.id)}
aria-label="Edit recipe"
>
✏️ Edit
</button>
</div>
</div>
</article>
);
}Conditional Rendering
// Multiple conditional patterns
function RecipeList({ recipes, loading, error }) {
// Pattern 1: Loading state
if (loading) {
return <LoadingSpinner message="Loading recipes..." />;
}
// Pattern 2: Error state
if (error) {
return (
<ErrorMessage
title="Failed to load recipes"
message={error.message}
onRetry={() => window.location.reload()}
/>
);
}
// Pattern 3: Empty state (guard clause)
if (recipes.length === 0) {
return (
<EmptyState
icon="🍳"
title="No recipes found"
description="Be the first to add a recipe!"
/>
);
}
// Pattern 4: Render list
return (
<div className="recipe-list">
{recipes.map((recipe) => (
<RecipeCard key={recipe.id} recipe={recipe} />
))}
</div>
);
}
// Conditional rendering with ternary
function RecipeStatus({ status }) {
return (
<span className={`status-badge status-${status}`}>
{status === 'published' && '✅ Published'}
{status === 'pending' && '⏳ Pending'}
{status === 'rejected' && '❌ Rejected'}
</span>
);
}
// Conditional rendering with logical &&
function FavoriteButton({ isFavorited, onToggle }) {
return (
<button
className="favorite-button"
onClick={onToggle}
aria-label={isFavorited ? 'Remove from favorites' : 'Add to favorites'}
>
{isFavorited && <span className="fill">⭐</span>}
{!isFavorited && <span className="outline">☆</span>}
</button>
);
}Lists & Keys
// Correct key usage patterns
function IngredientList({ ingredients }) {
// ✅ GOOD: Using unique IDs as keys
return (
<ul className="ingredient-list">
{ingredients.map((ingredient) => (
<li key={ingredient.id} className="ingredient-item">
<span className="name">{ingredient.name}</span>
<span className="quantity">
{ingredient.quantity} {ingredient.unit}
</span>
</li>
))}
</ul>
);
}
// ✅ GOOD: Using index as key ONLY when list is static and filtered
function FilterableList({ items, filter }) {
const filtered = items.filter(item =>
item.name.toLowerCase().includes(filter.toLowerCase())
);
return (
<ul>
{filtered.map((item, index) => (
<li key={`${item.id}-${index}`}>
{item.name}
</li>
))}
</ul>
);
}
// ❌ BAD: Using index as key for dynamic lists
function IngredientListBAD({ ingredients }) {
return (
<ul>
{ingredients.map((ingredient, index) => (
<li key={index}> {/* DON'T DO THIS */}
{ingredient.name}
</li>
))}
</ul>
);
}Advanced JSX Patterns
Composition
// Compound components for flexible composition
function RecipeCard({ children, featured = false }) {
return (
<article className={`recipe-card ${featured ? 'featured' : ''}`}>
{children}
</article>
);
}
RecipeCard.Image = ({ src, alt }) => (
<div className="recipe-card__image">
<img src={src} alt={alt} loading="lazy" />
</div>
);
RecipeCard.Header = ({ children }) => (
<header className="recipe-card__header">
{children}
</header>
);
RecipeCard.Body = ({ children }) => (
<div className="recipe-card__body">
{children}
</div>
);
RecipeCard.Footer = ({ children }) => (
<footer className="recipe-card__footer">
{children}
</footer>
);
// Usage
function RecipeDisplay({ recipe }) {
return (
<RecipeCard featured={recipe.featured}>
<RecipeCard.Image src={recipe.imageUrl} alt={recipe.title} />
<RecipeCard.Header>
<h2>{recipe.title}</h2>
<RecipeDifficulty difficulty={recipe.difficulty} />
</RecipeCard.Header>
<RecipeCard.Body>
<p>{recipe.description}</p>
<RecipeTags tags={recipe.tags} />
</RecipeCard.Body>
<RecipeCard.Footer>
<LikeButton recipeId={recipe.id} />
<FavoriteButton recipeId={recipe.id} />
</RecipeCard.Footer>
</RecipeCard>
);
}Render Props & Children
// Render prop for conditional rendering
function RecipeList({ recipes, loading, renderEmpty }) {
if (loading) return <LoadingSpinner />;
return (
<ul className="recipe-list">
{recipes.length === 0 ? (
renderEmpty?.()
) : (
recipes.map((recipe) => (
<RecipeCard key={recipe.id} recipe={recipe} />
))
)}
</ul>
);
}
// Usage
<RecipeList
recipes={recipes}
loading={loading}
renderEmpty={() => (
<EmptyState
title="No recipes yet"
description="Create your first recipe to get started!"
icon="🍳"
/>
)}
/>
// Children as render function
function DataList({ data, renderItem }) {
return (
<ul className="data-list">
{data.map((item, index) => renderItem?.(item, index))}
</ul>
);
}
function UsersPage() {
const users = useFetchUsers();
return (
<DataList
data={users}
renderItem={(user, index) => (
<li key={user.id} className="user-item">
<span className="rank">#{index + 1}</span>
<span className="name">{user.name}</span>
</li>
)}
/>
);
}Higher-Order Components (HOCs)
// HOC for loading state
function withLoading(WrappedComponent) {
return function LoadingHOC(props) {
const [loading, setLoading] = useState(false);
return (
<>
{loading && <GlobalLoader />}
<WrappedComponent
{...props}
setLoading={setLoading}
isLoading={loading}
/>
</>
);
};
}
// Usage
const RecipeFormWithLoading = withLoading(RecipeForm);
function CreateRecipe() {
return (
<RecipeFormWithLoading
onSubmit={handleSubmit}
onCancel={handleCancel}
/>
);
}
// HOC for authentication check
function withAuth(WrappedComponent) {
return function AuthenticatedComponent(props) {
const user = useAuth();
if (!user) {
return <Navigate to="/login" replace />;
}
return <WrappedComponent user={user} {...props} />;
};
}
// Usage
const UserProfile = withAuth(function Profile({ user }) {
return (
<div className="profile">
<h1>{user.name}</h1>
{/* Profile content */}
</div>
);
});Forms in JSX
Controlled Components
import { useState } from 'react';
/**
* RecipeForm component with controlled inputs and validation
*/
export function RecipeForm({ initialData, onSubmit, onCancel }) {
const [formData, setFormData] = useState({
title: initialData?.title || '',
description: initialData?.description || '',
category: initialData?.category || 'Uncategorized',
difficulty: initialData?.difficulty || 'Medium',
prepTime: initialData?.prepTime || 0,
cookTime: initialData?.cookTime || 0,
servings: initialData?.servings || 1,
});
const [errors, setErrors] = useState({});
const [isSubmitting, setIsSubmitting] = useState(false);
const validateField = (name, value) => {
const newErrors = { ...errors };
switch (name) {
case 'title':
if (value.length < 3) {
newErrors.title = 'Title must be at least 3 characters';
} else if (value.length > 200) {
newErrors.title = 'Title must be less than 200 characters';
} else {
delete newErrors.title;
}
break;
case 'description':
if (value.length > 1000) {
newErrors.description = 'Description is too long';
} else {
delete newErrors.description;
}
break;
case 'prepTime':
case 'cookTime':
if (value < 0) {
newErrors[name] = 'Time must be positive';
} else {
delete newErrors[name];
}
break;
default:
break;
}
setErrors(newErrors);
};
const handleChange = (event) => {
const { name, value, type } = event.target;
setFormData(prev => ({
...prev,
[name]: type === 'number' ? parseInt(value) || 0 : value,
}));
validateField(name, value);
};
const handleSubmit = async (event) => {
event.preventDefault();
// Check for remaining errors
if (Object.keys(errors).length > 0) {
setIsSubmitting(true);
try {
await onSubmit(formData);
} catch (error) {
setErrors({ submit: error.message });
} finally {
setIsSubmitting(false);
}
}
};
return (
<form onSubmit={handleSubmit} className="recipe-form" noValidate>
<div className="form-group">
<label htmlFor="title">
Recipe Title <span className="required">*</span>
</label>
<input
id="title"
name="title"
type="text"
value={formData.title}
onChange={handleChange}
className={errors.title ? 'error' : ''}
aria-invalid={!!errors.title}
aria-describedby="title-error"
required
maxLength={200}
/>
{errors.title && (
<span id="title-error" className="error-message">
{errors.title}
</span>
)}
</div>
<div className="form-group">
<label htmlFor="description">Description</label>
<textarea
id="description"
name="description"
value={formData.description}
onChange={handleChange}
className={errors.description ? 'error' : ''}
rows={4}
maxLength={1000}
aria-invalid={!!errors.description}
aria-describedby="description-error"
/>
{errors.description && (
<span id="description-error" className="error-message">
{errors.description}
</span>
)}
</div>
<div className="form-row">
<div className="form-group">
<label htmlFor="category">Category</label>
<select
id="category"
name="category"
value={formData.category}
onChange={handleChange}
>
<option value="Uncategorized">Uncategorized</option>
<option value="Breakfast">Breakfast</option>
<option value="Lunch">Lunch</option>
<option value="Dinner">Dinner</option>
<option value="Dessert">Dessert</option>
</select>
</div>
<div className="form-group">
<label htmlFor="difficulty">Difficulty</label>
<select
id="difficulty"
name="difficulty"
value={formData.difficulty}
onChange={handleChange}
>
<option value="Easy">Easy</option>
<option value="Medium">Medium</option>
<option value="Hard">Hard</option>
</select>
</div>
</div>
<div className="form-row">
<div className="form-group">
<label htmlFor="prepTime">Prep Time (minutes)</label>
<input
id="prepTime"
name="prepTime"
type="number"
min="0"
value={formData.prepTime}
onChange={handleChange}
aria-invalid={!!errors.prepTime}
aria-describedby="prepTime-error"
/>
{errors.prepTime && (
<span id="prepTime-error" className="error-message">
{errors.prepTime}
</span>
)}
</div>
<div className="form-group">
<label htmlFor="cookTime">Cook Time (minutes)</label>
<input
id="cookTime"
name="cookTime"
type="number"
min="0"
value={formData.cookTime}
onChange={handleChange}
aria-invalid={!!errors.cookTime}
aria-describedby="cookTime-error"
/>
{errors.cookTime && (
<span id="cookTime-error" className="error-message">
{errors.cookTime}
</span>
)}
</div>
</div>
{errors.submit && (
<div className="form-error" role="alert">
{errors.submit}
</div>
)}
<div className="form-actions">
<button
type="button"
onClick={onCancel}
disabled={isSubmitting}
className="button secondary"
>
Cancel
</button>
<button
type="submit"
disabled={isSubmitting || Object.keys(errors).length > 0}
className="button primary"
>
{isSubmitting ? 'Saving...' : 'Save Recipe'}
</button>
</div>
</form>
);
}Dynamic Forms with Arrays
import { useState } from 'react';
export function IngredientListForm({ ingredients, onChange }) {
const [newIngredient, setNewIngredient] = useState({
name: '',
quantity: '',
unit: '',
});
const handleAddIngredient = () => {
if (newIngredient.name.trim()) {
onChange?.([...ingredients, { ...newIngredient }]);
setNewIngredient({ name: '', quantity: '', unit: '' });
}
};
const handleRemoveIngredient = (index) => {
onChange?.(ingredients.filter((_, i) => i !== index));
};
const handleIngredientChange = (index, field) => (event) => {
const updated = ingredients.map((ingredient, i) =>
i === index
? { ...ingredient, [field]: event.target.value }
: ingredient
);
onChange?.(updated);
};
return (
<div className="ingredient-list-form">
<div className="ingredient-inputs">
<input
type="text"
placeholder="Ingredient name"
value={newIngredient.name}
onChange={(e) => setNewIngredient({ ...newIngredient, name: e.target.value })}
/>
<input
type="text"
placeholder="Quantity"
value={newIngredient.quantity}
onChange={(e) => setNewIngredient({ ...newIngredient, quantity: e.target.value })}
/>
<input
type="text"
placeholder="Unit (e.g., cups, grams)"
value={newIngredient.unit}
onChange={(e) => setNewIngredient({ ...newIngredient, unit: e.target.value })}
/>
<button
type="button"
onClick={handleAddIngredient}
disabled={!newIngredient.name.trim()}
className="button small"
>
+ Add
</button>
</div>
{ingredients.map((ingredient, index) => (
<div key={index} className="ingredient-row">
<input
type="text"
value={ingredient.name}
onChange={handleIngredientChange(index, 'name')}
placeholder="Ingredient name"
/>
<input
type="text"
value={ingredient.quantity}
onChange={handleIngredientChange(index, 'quantity')}
placeholder="Quantity"
/>
<input
type="text"
value={ingredient.unit}
onChange={handleIngredientChange(index, 'unit')}
placeholder="Unit"
/>
<button
type="button"
onClick={() => handleRemoveIngredient(index)}
className="button danger small"
aria-label={`Remove ${ingredient.name}`}
>
✕
</button>
</div>
))}
</div>
);
}Performance JSX Patterns
Avoiding Inline Functions
// ❌ BAD: Creating new function on every render
function RecipeList({ recipes }) {
return (
<ul>
{recipes.map((recipe) => (
<RecipeCard
key={recipe.id}
recipe={recipe}
onLike={() => handleLike(recipe.id)} {/* Creates new function each render */}
onDelete={() => handleDelete(recipe.id)}
/>
))}
</ul>
);
}
// ✅ GOOD: Using data attributes or useCallback
function RecipeList({ recipes, onLike, onDelete }) {
return (
<ul>
{recipes.map((recipe) => (
<RecipeCard
key={recipe.id}
recipe={recipe}
onLike={() => onLike(recipe.id)}
onDelete={() => onDelete(recipe.id)}
/>
))}
</ul>
);
}
// ✅ EVEN BETTER: Event delegation
function RecipeList({ recipes }) {
const handleAction = (event) => {
const button = event.target.closest('[data-recipe-action]');
if (!button) return;
const { action, recipeId } = button.dataset;
switch (action) {
case 'like':
handleLike(parseInt(recipeId));
break;
case 'delete':
handleDelete(parseInt(recipeId));
break;
}
};
return (
<ul onClick={handleAction}>
{recipes.map((recipe) => (
<li key={recipe.id} data-recipe-id={recipe.id}>
<h3>{recipe.title}</h3>
<button
data-recipe-action="like"
data-recipe-id={recipe.id}
type="button"
>
Like
</button>
<button
data-recipe-action="delete"
data-recipe-id={recipe.id}
type="button"
>
Delete
</button>
</li>
))}
</ul>
);
}Lazy Loading Components
import { lazy, Suspense } from 'react';
// Lazy load heavy components
const RecipeEditor = lazy(() => import('./RecipeEditor'));
const AdminDashboard = lazy(() => import('./AdminDashboard'));
const UserProfile = lazy(() => import('./UserProfile'));
// Loading fallback component
function ComponentLoader() {
return (
<div className="component-loader">
<div className="loader-spinner"></div>
<p>Loading component...</p>
</div>
);
}
// Usage with Suspense
function App() {
return (
<Suspense fallback={<ComponentLoader />}>
<Routes>
<Route path="/recipes/new" element={<RecipeEditor />} />
<Route path="/admin" element={<AdminDashboard />} />
<Route path="/profile" element={<UserProfile />} />
</Routes>
</Suspense>
);
}Accessibility in JSX
ARIA Attributes
function RecipeCard({ recipe, onLike }) {
const [isLiked, setIsLiked] = useState(false);
const [isExpanded, setIsExpanded] = useState(false);
return (
<article
className="recipe-card"
aria-labelledby={`recipe-title-${recipe.id}`}
aria-describedby={`recipe-desc-${recipe.id}`}
>
<img
src={recipe.imageUrl}
alt={recipe.title}
loading="lazy"
role="img"
/>
<h2 id={`recipe-title-${recipe.id}`}>{recipe.title}</h2>
<p id={`recipe-desc-${recipe.id}`} className="description">
{recipe.description}
</p>
<button
className="like-button"
onClick={() => setIsLiked(!isLiked)}
aria-label={isLiked ? 'Remove from likes' : 'Add to likes'}
aria-pressed={isLiked}
>
{isLiked ? '❤️ Liked' : '🤍 Like'}
</button>
<button
className="expand-button"
onClick={() => setIsExpanded(!isExpanded)}
aria-expanded={isExpanded}
aria-controls={`details-${recipe.id}`}
>
{isExpanded ? 'Show Less' : 'Show More'}
</button>
{isExpanded && (
<div id={`details-${recipe.id}`} className="recipe-details">
<RecipeIngredients ingredients={recipe.ingredients} />
<RecipeInstructions instructions={recipe.instructions} />
</div>
)}
</article>
);
}
// Accessible form with ARIA
function SearchForm({ onSearch }) {
const [query, setQuery] = useState('');
return (
<form
role="search"
onSubmit={(e) => {
e.preventDefault();
onSearch(query);
}}
aria-label="Search recipes"
>
<label htmlFor="search-input" className="sr-only">
Search recipes
</label>
<div className="search-input-wrapper">
<input
id="search-input"
type="search"
placeholder="Search recipes..."
value={query}
onChange={(e) => setQuery(e.target.value)}
aria-describedby="search-hint"
autoComplete="off"
/>
<button
type="submit"
aria-label="Submit search"
disabled={!query.trim()}
>
🔍
</button>
</div>
<p id="search-hint" className="hint-text">
Type at least 2 characters to search
</p>
</form>
);
}Keyboard Navigation
import { useEffect, useRef } from 'react';
function Modal({ isOpen, onClose, title, children, ariaLabel }) {
const modalRef = useRef(null);
const closeButtonRef = useRef(null);
// Focus trap
useEffect(() => {
if (isOpen && closeButtonRef.current) {
closeButtonRef.current.focus();
}
const handleEscape = (event) => {
if (event.key === 'Escape' && isOpen) {
onClose();
}
};
const trapFocus = (event) => {
if (!isOpen) return;
const focusableElements = modalRef.current?.querySelectorAll(
'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])'
);
const firstElement = focusableElements[0];
const lastElement = focusableElements[focusableElements.length - 1];
if (event.key === 'Tab') {
if (event.shiftKey) {
// Shift+Tab: Focus last element
if (document.activeElement === firstElement) {
event.preventDefault();
lastElement.focus();
}
} else {
// Tab: Focus first element
if (document.activeElement === lastElement) {
event.preventDefault();
firstElement.focus();
}
}
}
};
document.addEventListener('keydown', handleEscape);
document.addEventListener('keydown', trapFocus);
return () => {
document.removeEventListener('keydown', handleEscape);
document.removeEventListener('keydown', trapFocus);
};
}, [isOpen, onClose]);
// Body scroll lock
useEffect(() => {
if (isOpen) {
document.body.style.overflow = 'hidden';
} else {
document.body.style.overflow = '';
}
}, [isOpen]);
if (!isOpen) return null;
return (
<div
className="modal-overlay"
onClick={onClose}
role="presentation"
>
<div
ref={modalRef}
className="modal-content"
onClick={(e) => e.stopPropagation()}
role="dialog"
aria-modal="true"
aria-labelledby={`modal-title-${title}`}
aria-label={ariaLabel}
>
<div className="modal-header">
<h2 id={`modal-title-${title}`} className="modal-title">
{title}
</h2>
<button
ref={closeButtonRef}
className="modal-close"
onClick={onClose}
aria-label="Close modal"
>
✕
</button>
</div>
<div className="modal-body">
{children}
</div>
</div>
</div>
);
}Summary
Key React 19+ Features
- Server Components (if using Next.js)
- useActionState hook for form submissions
- useOptimistic hook for optimistic UI updates
- Automatic batching for multiple state updates
- Improved TypeScript support and type inference
Best Practices
- Use functional components with hooks
- Keep components small and focused
- Implement proper error boundaries
- Add loading and error states
- Use TypeScript for type safety
- Test components with React Testing Library
- Follow accessibility guidelines (ARIA, keyboard nav)
- Optimize performance with React.memo and memoization
MIT License
Copyright (c) 2026 Sithu Win San
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.React Hooks Reference (React 19+)
Quick-reference for every built-in React hook. Each entry includes signature, when to use, patterns, gotchas, and examples.
---
Table of Contents
- State Hooks
- useState
- useReducer
- Context Hooks
- useContext
- Ref Hooks
- useRef
- useImperativeHandle
- Effect Hooks
- useEffect
- useLayoutEffect
- Performance Hooks
- useMemo
- useCallback
- Transition Hooks
- useTransition
- useDeferredValue
- Identity Hooks
- useId
- Debug Hooks
- useDebugValue
- External Store Hooks
- useSyncExternalStore
- React 19 Hooks
- use
- useOptimistic
- useActionState
- useFormStatus
- React 19 Actions
---
State Hooks
useState
const [state, setState] = useState<T>(initialValue: T | (() => T)): [T, Dispatch<SetStateAction<T>>]When to use: Local component state for primitive values, objects, or arrays.
Common patterns:
// Primitive
const [count, setCount] = useState(0);
// Lazy initialization (expensive computation runs once)
const [data, setData] = useState(() => expensiveComputation());
// Functional update (when new state depends on previous)
setCount(prev => prev + 1);
// Object state
const [form, setForm] = useState({ name: '', email: '' });
setForm(prev => ({ ...prev, name: 'Alice' }));Gotchas:
setStatedoes NOT merge objects—always spread previous state for partial updates.- State updates are batched in React 18+/19. Multiple
setStatecalls in an event handler produce one re-render. - Initializer function runs only on mount. Don't pass a function call:
useState(fn())runs every render; useuseState(fn)oruseState(() => fn()). - Setting state to the same reference (Object.is) skips re-render.
---
useReducer
const [state, dispatch] = useReducer<R>(
reducer: R,
initialArg: ReducerStateWithoutAction<R>,
init?: (arg: ReducerStateWithoutAction<R>) => ReducerState<R>
): [ReducerState<R>, Dispatch<ReducerAction<R>>]When to use: Complex state logic with multiple sub-values, or when next state depends on previous state with defined action types.
Common patterns:
type State = { count: number; step: number };
type Action =
| { type: 'increment' }
| { type: 'decrement' }
| { type: 'setStep'; payload: number };
function reducer(state: State, action: Action): State {
switch (action.type) {
case 'increment': return { ...state, count: state.count + state.step };
case 'decrement': return { ...state, count: state.count - state.step };
case 'setStep': return { ...state, step: action.payload };
}
}
const [state, dispatch] = useReducer(reducer, { count: 0, step: 1 });
dispatch({ type: 'increment' });Gotchas:
- Reducer must be pure—no side effects.
- If reducer returns the same reference, React skips re-render.
- Third
initargument enables lazy initialization:useReducer(reducer, initialArg, init).
---
Context Hooks
useContext
const value = useContext<T>(SomeContext: React.Context<T>): TWhen to use: Consume values from a React.createContext provider without prop drilling.
Common patterns:
// Define
const ThemeContext = createContext<'light' | 'dark'>('light');
// Provide
<ThemeContext.Provider value="dark">
<App />
</ThemeContext.Provider>
// Consume
function Button() {
const theme = useContext(ThemeContext);
return <button className={theme === 'dark' ? 'bg-gray-800' : 'bg-white'}>Click</button>;
}Gotchas:
- Every consumer re-renders when the context value changes (by reference).
- Split contexts (state vs dispatch) to minimize re-renders.
- Wrap provider value in
useMemoif it's a computed object.
---
Ref Hooks
useRef
const ref = useRef<T>(initialValue: T): MutableRefObject<T>
const ref = useRef<T>(null): RefObject<T> // DOM refsWhen to use: Persist mutable values across renders without triggering re-renders. DOM element references.
Common patterns:
// DOM ref
const inputRef = useRef<HTMLInputElement>(null);
useEffect(() => { inputRef.current?.focus(); }, []);
return <input ref={inputRef} />;
// Mutable value (previous value, interval ID, etc.)
const intervalRef = useRef<ReturnType<typeof setInterval> | null>(null);Gotchas:
- Mutating
.currentdoes NOT cause a re-render. - Don't read/write
.currentduring rendering (except initialization). - For callback refs, use a function:
<div ref={(node) => { /* ... */ }} />.
---
useImperativeHandle
useImperativeHandle<T, R extends T>(
ref: ForwardedRef<T>,
createHandle: () => R,
deps?: DependencyList
): voidWhen to use: Customize the instance value exposed to parent components when using forwardRef.
Common patterns:
interface InputHandle {
focus: () => void;
scrollIntoView: () => void;
}
const FancyInput = forwardRef<InputHandle, Props>((props, ref) => {
const inputRef = useRef<HTMLInputElement>(null);
useImperativeHandle(ref, () => ({
focus: () => inputRef.current?.focus(),
scrollIntoView: () => inputRef.current?.scrollIntoView({ behavior: 'smooth' }),
}));
return <input ref={inputRef} {...props} />;
});Gotchas:
- Avoid overusing—prefer declarative patterns.
- Must be used with
forwardRef(or React 19's ref-as-prop). - React 19:
refis available as a regular prop, reducing need forforwardRef.
---
Effect Hooks
useEffect
useEffect(setup: () => (void | (() => void)), deps?: DependencyList): voidWhen to use: Synchronize with external systems (APIs, subscriptions, DOM manipulation, timers).
Common patterns:
// Fetch data
useEffect(() => {
const controller = new AbortController();
fetch('/api/data', { signal: controller.signal })
.then(res => res.json())
.then(setData)
.catch(err => {
if (err.name !== 'AbortError') setError(err);
});
return () => controller.abort();
}, []);
// Subscribe to external store
useEffect(() => {
const unsubscribe = store.subscribe(handleChange);
return () => unsubscribe();
}, []);
// Run once on mount
useEffect(() => { /* ... */ }, []);
// Run on every render (rare)
useEffect(() => { /* ... */ });Gotchas:
- Missing dependencies cause stale closures. Use the exhaustive-deps lint rule.
- Cleanup function runs before each re-execution AND on unmount.
- Effects run after paint (not before)—use
useLayoutEffectfor DOM measurements. - In React 18+ Strict Mode, effects mount → unmount → mount in dev to surface cleanup bugs.
- Don't use effects for data that can be computed during render (use
useMemo). - Don't use effects to respond to events (use event handlers instead).
---
useLayoutEffect
useLayoutEffect(setup: () => (void | (() => void)), deps?: DependencyList): voidWhen to use: Same as useEffect but fires synchronously after DOM mutations, before the browser paints. For DOM measurements and synchronous visual updates.
Common patterns:
// Measure DOM and adjust layout before paint
useLayoutEffect(() => {
const { height } = ref.current!.getBoundingClientRect();
setTooltipPosition(height);
}, []);Gotchas:
- Blocks visual updates—keep logic minimal.
- Prefer
useEffectunless you see visual flickering. - Does NOT fire on the server (SSR). Use
useEffectfor SSR-safe code.
---
Performance Hooks
useMemo
const memoizedValue = useMemo<T>(factory: () => T, deps: DependencyList): TWhen to use: Cache expensive computations. Prevent re-creating reference values (objects, arrays) that would cause child re-renders.
Common patterns:
// Expensive computation
const sorted = useMemo(() => items.sort(compareFn), [items]);
// Stable object reference for context value
const contextValue = useMemo(() => ({ user, logout }), [user, logout]);Gotchas:
- Not a semantic guarantee—React may discard cached values under memory pressure.
- Don't use for side effects—that's what
useEffectis for. - Profile before adding; premature memoization adds complexity.
- React Compiler (React 19) can auto-memoize, reducing manual
useMemoneeds.
---
useCallback
const memoizedCallback = useCallback<T extends Function>(callback: T, deps: DependencyList): TWhen to use: Stabilize function references passed to memoized children (React.memo) or used as effect dependencies.
Common patterns:
const handleClick = useCallback((id: string) => {
setItems(prev => prev.filter(item => item.id !== id));
}, []);
// Equivalent to:
const handleClick = useMemo(() => (id: string) => {
setItems(prev => prev.filter(item => item.id !== id));
}, []);Gotchas:
- Only useful when the consuming component is wrapped in
React.memoor the function is in a dependency array. useCallback(fn, deps)is syntactic sugar foruseMemo(() => fn, deps).- React Compiler (React 19) can auto-memoize callbacks.
---
Transition Hooks
useTransition
const [isPending, startTransition] = useTransition(): [boolean, (callback: () => void) => void]When to use: Mark state updates as non-urgent (transitions). Keeps the UI responsive during expensive re-renders.
Common patterns:
const [isPending, startTransition] = useTransition();
const [query, setQuery] = useState('');
const [results, setResults] = useState<Item[]>([]);
function handleChange(e: React.ChangeEvent<HTMLInputElement>) {
setQuery(e.target.value); // Urgent: update input immediately
startTransition(() => {
setResults(filterItems(e.target.value)); // Non-urgent: can be interrupted
});
}
return (
<>
<input value={query} onChange={handleChange} />
{isPending ? <Spinner /> : <ResultsList items={results} />}
</>
);Gotchas:
- The callback passed to
startTransitionmust be synchronous. - Transition updates can be interrupted by urgent updates.
- In React 19,
startTransitionsupports async functions (Actions).
---
useDeferredValue
const deferredValue = useDeferredValue<T>(value: T, initialValue?: T): TWhen to use: Defer re-rendering of a non-urgent part of the UI. Alternative to useTransition when you don't control the state update.
Common patterns:
function SearchResults({ query }: { query: string }) {
const deferredQuery = useDeferredValue(query);
const isStale = query !== deferredQuery;
const results = useMemo(() => filterItems(deferredQuery), [deferredQuery]);
return (
<div style={{ opacity: isStale ? 0.7 : 1 }}>
<ResultsList items={results} />
</div>
);
}Gotchas:
- React 19 adds optional
initialValueparameter for initial render. - Returns the previous value during transitions, then updates.
- Combine with
useMemoto avoid re-computing with stale value.
---
Identity Hooks
useId
const id = useId(): stringWhen to use: Generate unique IDs for accessibility attributes (htmlFor, aria-describedby). Safe for SSR hydration.
Common patterns:
function FormField({ label }: { label: string }) {
const id = useId();
return (
<>
<label htmlFor={id}>{label}</label>
<input id={id} />
</>
);
}
// Multiple related IDs
function PasswordField() {
const id = useId();
return (
<>
<label htmlFor={`${id}-password`}>Password</label>
<input id={`${id}-password`} aria-describedby={`${id}-hint`} />
<p id={`${id}-hint`}>Must be 8+ characters</p>
</>
);
}Gotchas:
- Do NOT use for list keys—use data-based keys instead.
- IDs are opaque strings (e.g.,
:r1:)—don't parse or depend on format. - Safe across server and client (hydration-stable).
---
Debug Hooks
useDebugValue
useDebugValue<T>(value: T, format?: (value: T) => any): voidWhen to use: Display a label for custom hooks in React DevTools.
Common patterns:
function useOnlineStatus(): boolean {
const isOnline = useSyncExternalStore(subscribe, getSnapshot);
useDebugValue(isOnline ? '🟢 Online' : '🔴 Offline');
return isOnline;
}
// Lazy formatting (expensive computations)
useDebugValue(date, d => d.toISOString());Gotchas:
- Only visible in React DevTools—no runtime impact.
- Use the format function to defer expensive formatting.
- Only use in custom hooks, not regular components.
---
External Store Hooks
useSyncExternalStore
const snapshot = useSyncExternalStore<T>(
subscribe: (onStoreChange: () => void) => () => void,
getSnapshot: () => T,
getServerSnapshot?: () => T
): TWhen to use: Subscribe to external data stores (Redux, Zustand, browser APIs, third-party state).
Common patterns:
// Browser online status
function useOnlineStatus() {
return useSyncExternalStore(
(callback) => {
window.addEventListener('online', callback);
window.addEventListener('offline', callback);
return () => {
window.removeEventListener('online', callback);
window.removeEventListener('offline', callback);
};
},
() => navigator.onLine,
() => true // Server snapshot
);
}
// External store
const snapshot = useSyncExternalStore(
store.subscribe,
store.getSnapshot,
store.getServerSnapshot
);Gotchas:
getSnapshotmust return a cached/immutable value. Returning a new object each call causes infinite re-renders.getServerSnapshotis required for SSR.- Prefer this over
useEffect+useStatefor external subscriptions.
---
React 19 Hooks
use
const value = use<T>(resource: Promise<T> | Context<T>): TWhen to use: Read a Promise or Context value inside a component. Unlike other hooks, use can be called conditionally and inside loops.
Common patterns:
// Read a promise (with Suspense)
function UserProfile({ userPromise }: { userPromise: Promise<User> }) {
const user = use(userPromise);
return <h1>{user.name}</h1>;
}
// Wrap in Suspense
<Suspense fallback={<Spinner />}>
<UserProfile userPromise={fetchUser(id)} />
</Suspense>
// Read context conditionally
function StatusBar({ showTheme }: { showTheme: boolean }) {
if (showTheme) {
const theme = use(ThemeContext);
return <span>{theme}</span>;
}
return null;
}Gotchas:
- When reading a Promise, must be wrapped in a
<Suspense>boundary. - The Promise must be created outside the component (in a loader, server component, or parent). Creating it during render causes infinite suspension.
- Can replace
useContextwhere conditional reads are needed. - Unlike other hooks, does NOT need to follow the rules of hooks (can be inside if/loops).
---
useOptimistic
const [optimisticState, addOptimistic] = useOptimistic<State, Update>(
state: State,
updateFn: (currentState: State, optimisticValue: Update) => State
): [State, (action: Update) => void]When to use: Show optimistic UI updates while an async action (form submission, mutation) is in progress.
Common patterns:
type Message = { text: string; sending?: boolean };
function Chat({ messages, sendMessage }: Props) {
const [optimisticMessages, addOptimistic] = useOptimistic<Message[], string>(
messages,
(state, newMessage) => [
...state,
{ text: newMessage, sending: true },
]
);
async function handleSubmit(formData: FormData) {
const text = formData.get('message') as string;
addOptimistic(text);
await sendMessage(text);
}
return (
<form action={handleSubmit}>
{optimisticMessages.map((msg, i) => (
<p key={i} style={{ opacity: msg.sending ? 0.6 : 1 }}>{msg.text}</p>
))}
<input name="message" />
<button type="submit">Send</button>
</form>
);
}Gotchas:
- Optimistic state reverts automatically when the async action completes.
- Best paired with
<form action={...}>(React 19 Actions). - The update function must be pure.
---
useActionState
const [state, formAction, isPending] = useActionState<State, Payload>(
action: (previousState: State, payload: Payload) => State | Promise<State>,
initialState: State,
permalink?: string
): [State, (payload: Payload) => void, boolean]When to use: Manage form state with server or client actions. Replaces the useFormState hook (renamed in React 19).
Common patterns:
interface FormState {
error: string | null;
success: boolean;
}
async function submitAction(prev: FormState, formData: FormData): Promise<FormState> {
const email = formData.get('email') as string;
try {
await subscribe(email);
return { error: null, success: true };
} catch (e) {
return { error: (e as Error).message, success: false };
}
}
function Newsletter() {
const [state, action, isPending] = useActionState(submitAction, {
error: null,
success: false,
});
return (
<form action={action}>
<input name="email" type="email" required />
<button disabled={isPending}>
{isPending ? 'Subscribing...' : 'Subscribe'}
</button>
{state.error && <p className="text-red-500">{state.error}</p>}
{state.success && <p className="text-green-600">Subscribed!</p>}
</form>
);
}Gotchas:
- Renamed from
useFormStatein React 19. - The action receives the previous state as first argument—useful for accumulating errors.
isPending(third return value) is unique touseActionState—no need for separateuseTransition.permalinkis optional, used for progressive enhancement with server actions.
---
useFormStatus
const { pending, data, method, action } = useFormStatus(): {
pending: boolean;
data: FormData | null;
method: string;
action: string | ((formData: FormData) => void) | null;
}When to use: Read the status of a parent <form> from within a child component. Must be rendered inside a <form>.
Common patterns:
function SubmitButton() {
const { pending } = useFormStatus();
return (
<button type="submit" disabled={pending} className="btn">
{pending ? 'Saving...' : 'Save'}
</button>
);
}
function MyForm() {
return (
<form action={saveAction}>
<input name="title" />
<SubmitButton /> {/* Must be a child of <form> */}
</form>
);
}Gotchas:
- Must be called from a component rendered inside a
<form>—will not work if called in the same component that renders the<form>. - Returns status of the nearest parent
<form>. - Only works with React 19's
<form action={...}>pattern.
---
React 19 Actions
Actions are a React 19 pattern (not a hook) that enable async functions in transitions.
// Form actions
<form action={async (formData) => {
await saveToServer(formData);
}}>
<input name="title" />
<button type="submit">Save</button>
</form>
// startTransition with async
const [isPending, startTransition] = useTransition();
function handleSave() {
startTransition(async () => {
await saveData();
// React waits for the async function to finish
});
}Key points:
<form action={fn}>callsfnwithFormDataon submit.startTransitionnow accepts async functions in React 19.- Actions automatically handle pending states, errors, and optimistic updates.
- Pair with
useActionStatefor form state anduseOptimisticfor optimistic UI.
---
Quick Decision Guide
| Need | Hook |
|---|---|
| Simple local state | useState |
| Complex state machine | useReducer |
| Shared state (no prop drilling) | useContext |
| DOM reference or mutable container | useRef |
| Side effect / sync with external system | useEffect |
| DOM measurement before paint | useLayoutEffect |
| Cache expensive computation | useMemo |
| Stable function reference | useCallback |
| Non-urgent state update | useTransition |
| Defer re-render of a value | useDeferredValue |
| SSR-safe unique IDs | useId |
| External store subscription | useSyncExternalStore |
| Read Promise or Context (conditional) | use |
| Optimistic UI during async mutation | useOptimistic |
| Form action state management | useActionState |
| Parent form submission status | useFormStatus |
React 19 JSX and API Updates (2026)
Up-to-date React 19 guidance relevant to JSX-heavy Vite apps.
Core React 19 Patterns
Declarative Components
- Prefer pure rendering logic in components.
- Keep side effects inside hooks (
useEffect,useLayoutEffect) only when needed. - Derive UI state from props/store where possible to reduce sync bugs.
Form Actions and Async Flows
- Use action-oriented handlers for submit/update/delete flows.
- Keep optimistic UI updates local and reversible.
- Surface validation errors as field-level messages and general API errors separately.
Suspense-friendly data loading
- Use route-level or boundary-level loading fallbacks.
- Split heavy subtrees with
React.lazy. - Avoid over-wrapping tiny components in
Suspense.
JSX Composition Patterns
function RecipeSection({ title, children }) {
return (
<section className="space-y-3">
<h2 className="text-lg font-semibold">{title}</h2>
<div>{children}</div>
</section>
);
}
function RecipeMeta({ prepTime, cookTime, servings }) {
return (
<dl className="grid grid-cols-3 gap-2 text-sm">
<div><dt>Prep</dt><dd>{prepTime} min</dd></div>
<div><dt>Cook</dt><dd>{cookTime} min</dd></div>
<div><dt>Servings</dt><dd>{servings}</dd></div>
</dl>
);
}Performance Notes
- Use stable keys from database IDs.
- Avoid unnecessary derived arrays in render; memoize expensive transforms.
- Prefer coarse memoization at list/section boundaries.
- Keep callback identity stable only when it prevents real rerenders.
Accessibility Notes
- Ensure interactive controls are real
button/aelements. - Provide labels/
aria-labelfor icon-only buttons. - Use semantic headings and landmark regions.
- Ensure modal/dialog focus management and escape handling.
References
- React docs: https://react.dev/
- React API reference: https://react.dev/reference/react
- Accessibility: https://react.dev/learn/accessibility
Related skills
FAQ
What does react-development do?
react-development is a Claude Code skill for frontend development.
When should I use react-development?
When you need to helps with frontend development tasks., or when react-development is a claude code skill for frontend development.
What are the main capabilities?
react-development; Frontend Development; AI-coding skill.