
React Agent
- 5 installs
- 2 repo stars
- Updated April 3, 2026
- eva813/vue3-skills
Helps with frontend development tasks.
About
react-agent is a Claude Code skill for frontend development. It helps solo builders move faster with AI-assisted coding.
- react-agent
- Frontend Development
- AI-coding skill
React Agent by the numbers
- 5 all-time installs (skills.sh)
- Ranked #1,790 of 2,245 Frontend Development skills by installs in the Skillselion catalog
- Data as of Jul 24, 2026 (Skillselion catalog sync)
npx skills add https://github.com/eva813/vue3-skills --skill react-agentAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 5 |
|---|---|
| repo stars | ★ 2 |
| Last updated | April 3, 2026 |
| Repository | eva813/vue3-skills ↗ |
What it does
Helps with frontend development tasks.
Files
React Agent
Pragmatic React development that prioritizes simplicity, maintainable state management, and optimal rendering performance.
Core Principles
1. Start Simple, Add Complexity Only When Needed
Default approach:
- Use local state (
useState) first - Lift state up when components need to share
- Add Context only when prop drilling becomes painful (>3 levels)
- Consider external state management (Zustand/Redux) only for truly global state
Red flags of over-engineering:
- Creating abstractions before they're needed
- Adding state management libraries for 2-3 shared values
- Implementing complex patterns for simple forms
- Premature optimization without measuring
2. State Management Decision Tree
Follow this decision tree when choosing state solutions:
Is this state used in only one component?
├─ YES → useState
└─ NO → Is it shared by 2-3 closely related components?
├─ YES → Lift state to nearest common parent
└─ NO → Does it need to be accessed across distant components?
├─ YES → Is it truly global (auth, theme, i18n)?
│ ├─ YES → Context API or Zustand
│ └─ NO → Still use props/composition when possible
└─ NO → Re-evaluate component structureQuick reference:
- 1 component →
useState - 2-3 related → Lift state + props
- Many components (same branch) → Context API
- App-wide global → Zustand (recommended) or Redux Toolkit
- Server data → TanStack Query or SWR
3. Rendering Optimization Rules
Only optimize when: 1. Profiler shows actual performance issue 2. Component re-renders are visibly slow 3. Working with large lists (>100 items) 4. Expensive computations are occurring
Optimization toolkit:
React.memo- for expensive components that receive same propsuseMemo- for expensive calculationsuseCallback- when passing callbacks to memoized childrenuseTransition- for non-urgent updates (React 18+)
Don't optimize prematurely:
- Avoid wrapping everything in
memoby default - Don't use
useMemofor simple calculations - Don't optimize before measuring
Quick Start Workflow
1. Analyze Requirements
// Ask yourself:
// - What data do I need? (local vs shared)
// - How many components need this data?
// - Is this data from server or client?
// - Are there performance concerns?2. Choose State Pattern
See references/state-patterns.md for detailed patterns and examples.
Local State Example:
function Counter() {
const [count, setCount] = useState(0);
return <button onClick={() => setCount(c => c + 1)}>{count}</button>;
}Lifted State Example:
function Parent() {
const [items, setItems] = useState([]);
return (
<>
<ItemList items={items} />
<ItemForm onAdd={(item) => setItems([...items, item])} />
</>
);
}3. Implement with TypeScript
// Always type props and state
interface Props {
userId: string;
onUpdate: (data: UserData) => void;
}
function UserProfile({ userId, onUpdate }: Props) {
const [isLoading, setIsLoading] = useState(false);
// implementation
}4. Add Optimization (Only If Needed)
// Measure first with React DevTools Profiler
// Then optimize specific bottlenecks
const MemoizedList = memo(ExpensiveList);
const handleClick = useCallback(() => {
// Only when passing to memoized children
}, [dependencies]);
const expensiveValue = useMemo(() => {
// Only for truly expensive calculations
return heavyComputation(data);
}, [data]);Common Patterns
Pattern 1: Form Handling
Simple form (controlled):
function SimpleForm() {
const [email, setEmail] = useState('');
const handleSubmit = (e: FormEvent) => {
e.preventDefault();
// handle submission
};
return (
<form onSubmit={handleSubmit}>
<input
value={email}
onChange={(e) => setEmail(e.target.value)}
/>
</form>
);
}Complex form: Use React Hook Form or Formik (only for 5+ fields)
Pattern 2: Data Fetching
// Prefer TanStack Query for server state
import { useQuery } from '@tanstack/react-query';
function UserProfile({ userId }: Props) {
const { data, isLoading, error } = useQuery({
queryKey: ['user', userId],
queryFn: () => fetchUser(userId),
});
if (isLoading) return <Spinner />;
if (error) return <Error error={error} />;
return <div>{data.name}</div>;
}Pattern 3: Conditional Rendering
// Simple ternary
{isLoading ? <Spinner /> : <Content />}
// Multiple conditions - extract to variable
const content = (() => {
if (isLoading) return <Spinner />;
if (error) return <Error />;
if (!data) return <Empty />;
return <Content data={data} />;
})();Performance Checklist
Before optimizing, verify with React DevTools Profiler: 1. Identify which components re-render 2. Measure render time 3. Check why re-renders happen (props/state/context)
Quick wins:
- Extract expensive operations outside component
- Use proper key props for lists (not index unless static)
- Split large components into smaller ones
- Use Code Splitting for large bundles (
React.lazy)
For lists:
// Virtualization for 100+ items
import { useVirtualizer } from '@tanstack/react-virtual';
// Key requirement: stable, unique IDs
{items.map(item => (
<Item key={item.id} {...item} />
))}Anti-Patterns to Avoid
❌ Over-abstraction:
// Don't create generic wrappers unnecessarily
<GenericForm config={complexConfig} />❌ Premature memoization:
// Don't wrap everything by default
export default memo(SimpleButton); // unnecessary❌ Prop drilling Context:
// Don't use Context just to avoid 2 levels of props
<Context.Provider value={simpleValue}>❌ Mutations:
// Never mutate state
items.push(newItem); // Wrong
setItems([...items, newItem]); // CorrectReferences
Load these references for deeper guidance:
- State Management →
references/state-patterns.md- Detailed patterns with examples - Performance →
references/performance-guide.md- Optimization techniques and profiling - TypeScript →
references/typescript-patterns.md- Type patterns and utilities - Hooks →
references/hooks-guide.md- Custom hooks and patterns - Testing →
references/testing-guide.md- Testing with React Testing Library
Key Constraints
MUST DO:
- Type all props and state with TypeScript
- Use stable keys for lists (not array index for dynamic lists)
- Clean up effects (return cleanup function)
- Handle loading and error states
- Validate before optimizing (use Profiler)
MUST NOT:
- Mutate state or props
- Use index as key for dynamic lists
- Create functions inside JSX
- Forget useEffect cleanup (memory leaks)
- Over-engineer simple solutions
- Optimize without measuring
React Hooks Guide
Practical patterns for built-in and custom React hooks.
Built-in Hooks
useState - Local State
// ✅ Simple value
const [count, setCount] = useState(0);
// ✅ Functional update (when new state depends on old)
setCount(prevCount => prevCount + 1);
// ✅ Lazy initialization (expensive computation)
const [state, setState] = useState(() => {
const initialState = expensiveComputation();
return initialState;
});
// ✅ Object state
const [form, setForm] = useState({ email: '', password: '' });
// Update single field
setForm(prev => ({ ...prev, email: newEmail }));When to use:
- Component-local state
- Simple toggles, counters, form inputs
- UI state (expanded/collapsed, selected item)
useEffect - Side Effects
// ✅ Run once on mount
useEffect(() => {
console.log('Component mounted');
}, []); // Empty dependency array
// ✅ Run on dependency change
useEffect(() => {
console.log('Count changed:', count);
}, [count]);
// ✅ Cleanup function
useEffect(() => {
const timer = setInterval(() => {
console.log('tick');
}, 1000);
return () => clearInterval(timer); // Cleanup
}, []);
// ✅ Async effect
useEffect(() => {
let cancelled = false;
async function fetchData() {
const data = await api.fetch();
if (!cancelled) {
setData(data);
}
}
fetchData();
return () => {
cancelled = true; // Prevent state update after unmount
};
}, []);Common patterns:
// Subscribe to events
useEffect(() => {
function handleResize() {
setWidth(window.innerWidth);
}
window.addEventListener('resize', handleResize);
return () => window.removeEventListener('resize', handleResize);
}, []);
// Set document title
useEffect(() => {
document.title = `Count: ${count}`;
}, [count]);
// Fetch data
useEffect(() => {
const controller = new AbortController();
fetch(url, { signal: controller.signal })
.then(res => res.json())
.then(setData)
.catch(err => {
if (err.name !== 'AbortError') {
setError(err);
}
});
return () => controller.abort();
}, [url]);Anti-patterns:
// ❌ Missing dependencies
useEffect(() => {
console.log(count); // Should be in deps
}, []);
// ❌ Unnecessary effect (use derived state)
useEffect(() => {
setTotal(price * quantity);
}, [price, quantity]);
// Should be: const total = price * quantity;
// ❌ Fetching in effect without cleanup
useEffect(() => {
fetch(url).then(res => res.json()).then(setData);
}, [url]); // Race condition - use TanStack Query insteaduseMemo - Memoize Values
// ✅ Expensive calculation
const expensiveValue = useMemo(() => {
return items
.filter(item => item.active)
.map(item => item.value)
.reduce((sum, val) => sum + val, 0);
}, [items]);
// ✅ Stable reference for dependencies
const filters = useMemo(() => ({
category: selectedCategory,
priceRange: [minPrice, maxPrice]
}), [selectedCategory, minPrice, maxPrice]);
useEffect(() => {
applyFilters(filters);
}, [filters]); // Won't re-run unless filters changeWhen to use:
- Expensive calculations (>5ms)
- Creating stable object/array references for dependencies
- Filtering/sorting large datasets
useCallback - Memoize Functions
// ✅ Callback for memoized child
const handleClick = useCallback((id: string) => {
console.log('Clicked', id);
}, []);
return <MemoizedList onItemClick={handleClick} />;
// ✅ With dependencies
const handleSubmit = useCallback(async (data: FormData) => {
await api.submit(data, userId); // userId in deps
}, [userId]);
// ✅ For effect dependencies
const fetchData = useCallback(async () => {
const data = await api.fetch(url);
setData(data);
}, [url]);
useEffect(() => {
fetchData();
}, [fetchData]); // Stable referenceWhen to use:
- Passing callbacks to memoized children
- Creating stable function references for dependencies
- Debounced/throttled functions
useRef - Mutable References
// ✅ DOM reference
function Input() {
const inputRef = useRef<HTMLInputElement>(null);
const focus = () => {
inputRef.current?.focus();
};
return (
<div>
<input ref={inputRef} />
<button onClick={focus}>Focus</button>
</div>
);
}
// ✅ Mutable value (doesn't trigger re-render)
function Timer() {
const intervalRef = useRef<number | null>(null);
const [count, setCount] = useState(0);
const start = () => {
if (!intervalRef.current) {
intervalRef.current = window.setInterval(() => {
setCount(c => c + 1);
}, 1000);
}
};
const stop = () => {
if (intervalRef.current) {
clearInterval(intervalRef.current);
intervalRef.current = null;
}
};
return (
<div>
<div>{count}</div>
<button onClick={start}>Start</button>
<button onClick={stop}>Stop</button>
</div>
);
}
// ✅ Store previous value
function usePrevious<T>(value: T): T | undefined {
const ref = useRef<T>();
useEffect(() => {
ref.current = value;
}, [value]);
return ref.current;
}When to use:
- DOM element references
- Storing values that don't trigger re-renders
- Persisting values across renders (timers, subscriptions)
- Storing previous state/props
useReducer - Complex State Logic
// ✅ Complex state with multiple actions
interface State {
items: Item[];
filter: string;
sort: 'asc' | 'desc';
loading: boolean;
}
type Action =
| { type: 'ADD_ITEM'; payload: Item }
| { type: 'REMOVE_ITEM'; payload: string }
| { type: 'SET_FILTER'; payload: string }
| { type: 'SET_SORT'; payload: 'asc' | 'desc' }
| { type: 'SET_LOADING'; payload: boolean };
function reducer(state: State, action: Action): State {
switch (action.type) {
case 'ADD_ITEM':
return { ...state, items: [...state.items, action.payload] };
case 'REMOVE_ITEM':
return { ...state, items: state.items.filter(i => i.id !== action.payload) };
case 'SET_FILTER':
return { ...state, filter: action.payload };
case 'SET_SORT':
return { ...state, sort: action.payload };
case 'SET_LOADING':
return { ...state, loading: action.payload };
default:
return state;
}
}
function List() {
const [state, dispatch] = useReducer(reducer, {
items: [],
filter: '',
sort: 'asc',
loading: false,
});
const addItem = (item: Item) => {
dispatch({ type: 'ADD_ITEM', payload: item });
};
return (
<div>
<input
value={state.filter}
onChange={(e) => dispatch({ type: 'SET_FILTER', payload: e.target.value })}
/>
<ItemList items={state.items} sort={state.sort} />
</div>
);
}When to use:
- Multiple related state values
- Complex state transitions
- State logic depends on previous state
- Multiple ways to update the same piece of state
Custom Hooks
Pattern: useLocalStorage
function useLocalStorage<T>(
key: string,
initialValue: T
): [T, (value: T) => void] {
const [storedValue, setStoredValue] = useState<T>(() => {
try {
const item = window.localStorage.getItem(key);
return item ? JSON.parse(item) : initialValue;
} catch (error) {
console.error(error);
return initialValue;
}
});
const setValue = (value: T) => {
try {
setStoredValue(value);
window.localStorage.setItem(key, JSON.stringify(value));
} catch (error) {
console.error(error);
}
};
return [storedValue, setValue];
}
// Usage
const [theme, setTheme] = useLocalStorage<'light' | 'dark'>('theme', 'light');Pattern: useDebounce
function useDebounce<T>(value: T, delay: number): T {
const [debouncedValue, setDebouncedValue] = useState(value);
useEffect(() => {
const timer = setTimeout(() => {
setDebouncedValue(value);
}, delay);
return () => clearTimeout(timer);
}, [value, delay]);
return debouncedValue;
}
// Usage
function SearchBox() {
const [query, setQuery] = useState('');
const debouncedQuery = useDebounce(query, 300);
useEffect(() => {
if (debouncedQuery) {
searchAPI(debouncedQuery);
}
}, [debouncedQuery]);
return <input value={query} onChange={(e) => setQuery(e.target.value)} />;
}Pattern: useWindowSize
function useWindowSize() {
const [size, setSize] = useState({
width: window.innerWidth,
height: window.innerHeight,
});
useEffect(() => {
const handleResize = () => {
setSize({
width: window.innerWidth,
height: window.innerHeight,
});
};
window.addEventListener('resize', handleResize);
return () => window.removeEventListener('resize', handleResize);
}, []);
return size;
}
// Usage
function Component() {
const { width } = useWindowSize();
const isMobile = width < 768;
return <div>{isMobile ? 'Mobile' : 'Desktop'}</div>;
}Pattern: useToggle
function useToggle(initialValue = false): [boolean, () => void] {
const [value, setValue] = useState(initialValue);
const toggle = useCallback(() => setValue(v => !v), []);
return [value, toggle];
}
// Usage
function Modal() {
const [isOpen, toggleOpen] = useToggle(false);
return (
<div>
<button onClick={toggleOpen}>Toggle</button>
{isOpen && <ModalContent onClose={toggleOpen} />}
</div>
);
}Pattern: useAsync
interface AsyncState<T> {
data: T | null;
loading: boolean;
error: Error | null;
}
function useAsync<T>(
asyncFunction: () => Promise<T>,
immediate = true
): AsyncState<T> & { execute: () => Promise<void> } {
const [state, setState] = useState<AsyncState<T>>({
data: null,
loading: immediate,
error: null,
});
const execute = useCallback(async () => {
setState({ data: null, loading: true, error: null });
try {
const data = await asyncFunction();
setState({ data, loading: false, error: null });
} catch (error) {
setState({ data: null, loading: false, error: error as Error });
}
}, [asyncFunction]);
useEffect(() => {
if (immediate) {
execute();
}
}, [execute, immediate]);
return { ...state, execute };
}
// Usage
function UserProfile({ userId }: { userId: string }) {
const { data, loading, error, execute } = useAsync(
() => api.fetchUser(userId),
true
);
if (loading) return <Spinner />;
if (error) return <Error error={error} retry={execute} />;
if (!data) return null;
return <div>{data.name}</div>;
}Pattern: useIntersectionObserver
function useIntersectionObserver(
ref: RefObject<Element>,
options?: IntersectionObserverInit
): boolean {
const [isIntersecting, setIsIntersecting] = useState(false);
useEffect(() => {
if (!ref.current) return;
const observer = new IntersectionObserver(([entry]) => {
setIsIntersecting(entry.isIntersecting);
}, options);
observer.observe(ref.current);
return () => observer.disconnect();
}, [ref, options]);
return isIntersecting;
}
// Usage - Lazy load images
function LazyImage({ src, alt }: { src: string; alt: string }) {
const ref = useRef<HTMLDivElement>(null);
const isVisible = useIntersectionObserver(ref, { threshold: 0.1 });
return (
<div ref={ref}>
{isVisible ? <img src={src} alt={alt} /> : <div>Loading...</div>}
</div>
);
}Pattern: useMediaQuery
function useMediaQuery(query: string): boolean {
const [matches, setMatches] = useState(() => {
return window.matchMedia(query).matches;
});
useEffect(() => {
const mediaQuery = window.matchMedia(query);
const handleChange = (e: MediaQueryListEvent) => setMatches(e.matches);
mediaQuery.addEventListener('change', handleChange);
return () => mediaQuery.removeEventListener('change', handleChange);
}, [query]);
return matches;
}
// Usage
function ResponsiveComponent() {
const isMobile = useMediaQuery('(max-width: 768px)');
const isTablet = useMediaQuery('(min-width: 769px) and (max-width: 1024px)');
return (
<div>
{isMobile && <MobileLayout />}
{isTablet && <TabletLayout />}
{!isMobile && !isTablet && <DesktopLayout />}
</div>
);
}Hook Best Practices
1. Rules of Hooks
// ✓ Call hooks at top level
function Component() {
const [state, setState] = useState(0);
const value = useMemo(() => compute(state), [state]);
// ...
}
// ✗ Don't call conditionally
function Component() {
if (condition) {
const [state, setState] = useState(0); // Wrong!
}
}
// ✗ Don't call in loops
function Component() {
items.forEach(item => {
const [state, setState] = useState(0); // Wrong!
});
}2. Extract Complex Logic to Custom Hooks
// ✓ Clean component
function SearchableList({ items }: Props) {
const { query, setQuery, filteredItems } = useSearch(items);
return (
<div>
<input value={query} onChange={(e) => setQuery(e.target.value)} />
<List items={filteredItems} />
</div>
);
}
// Logic in custom hook
function useSearch<T>(items: T[], searchKey: keyof T) {
const [query, setQuery] = useState('');
const filteredItems = useMemo(() => {
if (!query) return items;
return items.filter(item =>
String(item[searchKey]).toLowerCase().includes(query.toLowerCase())
);
}, [items, query, searchKey]);
return { query, setQuery, filteredItems };
}3. Use ESLint Rules
// .eslintrc
{
"plugins": ["react-hooks"],
"rules": {
"react-hooks/rules-of-hooks": "error",
"react-hooks/exhaustive-deps": "warn"
}
}4. Name Custom Hooks with "use" Prefix
// ✓ Correct
function useAuth() { ... }
function useLocalStorage() { ... }
function useFetch() { ... }
// ✗ Wrong
function getAuth() { ... }
function localStorage() { ... }
function fetchData() { ... }5. Return Object for Multiple Values
// ✓ Better - named return values
function useForm() {
return {
values,
errors,
handleChange,
handleSubmit,
isValid,
};
}
// Usage - clear what each value is
const { values, errors, handleSubmit } = useForm();
// ✗ Array return gets confusing with many values
function useForm() {
return [values, errors, handleChange, handleSubmit, isValid];
}
const [values, errors, change, submit, valid] = useForm(); // What's what?Common Mistakes
1. Unnecessary useEffect
// ❌ Don't use effect for derived state
const [firstName, setFirstName] = useState('');
const [lastName, setLastName] = useState('');
const [fullName, setFullName] = useState('');
useEffect(() => {
setFullName(`${firstName} ${lastName}`);
}, [firstName, lastName]);
// ✅ Calculate directly
const fullName = `${firstName} ${lastName}`;2. Stale Closures
// ❌ Captures old count value
const [count, setCount] = useState(0);
useEffect(() => {
const timer = setInterval(() => {
setCount(count + 1); // Always uses count from initial render
}, 1000);
return () => clearInterval(timer);
}, []);
// ✅ Use functional update
useEffect(() => {
const timer = setInterval(() => {
setCount(c => c + 1); // Always uses latest count
}, 1000);
return () => clearInterval(timer);
}, []);3. Missing Cleanup
// ❌ Memory leak
useEffect(() => {
const subscription = subscribe();
// Missing cleanup
}, []);
// ✅ Proper cleanup
useEffect(() => {
const subscription = subscribe();
return () => subscription.unsubscribe();
}, []);4. Infinite Loops
// ❌ Infinite loop - object created every render
const [count, setCount] = useState(0);
useEffect(() => {
setCount(count + 1);
}, [{ value: count }]); // New object every time
// ✅ Use primitive value
useEffect(() => {
setCount(count + 1);
}, [count]);Performance Optimization Guide
Practical guide to measuring and optimizing React performance.
Rule #1: Measure Before Optimizing
Always use React DevTools Profiler before optimization:
1. Open React DevTools → Profiler tab 2. Click Record → Perform action → Stop 3. Check:
- Which components re-rendered?
- How long did renders take?
- What caused the re-renders?
Only optimize if:
- User experiences visible lag
- Profiler shows render time >16ms (60fps) or >50ms (noticeable)
- Component re-renders unnecessarily many times
Optimization Toolkit
1. React.memo - Prevent Re-renders
Use when: Component is expensive and receives same props frequently
// ✅ Memoize expensive component
const ExpensiveChart = memo(({ data }: { data: ChartData }) => {
// Expensive rendering logic
return <ComplexVisualization data={data} />;
});
// Component only re-renders when data reference changes
function Dashboard() {
const [filter, setFilter] = useState('all');
const data = useMemo(() => processData(rawData, filter), [rawData, filter]);
return (
<div>
<FilterBar onFilterChange={setFilter} />
<ExpensiveChart data={data} /> {/* Won't re-render when filter changes */}
</div>
);
}Don't use when:
- Component is cheap to render (<1ms)
- Props change frequently anyway
- Adding memo creates more overhead than the render cost
Custom comparison:
// Use when default shallow comparison isn't sufficient
const MemoizedComponent = memo(
MyComponent,
(prevProps, nextProps) => {
// Return true if props are equal (skip re-render)
return prevProps.id === nextProps.id &&
prevProps.data.length === nextProps.data.length;
}
);2. useMemo - Cache Expensive Calculations
Use when: Calculation is expensive (>5ms) and runs on every render
// ✅ Expensive calculation
function DataTable({ items }: { items: Item[] }) {
// useMemo prevents recalculation on unrelated re-renders
const sortedAndFiltered = useMemo(() => {
return items
.filter(item => item.active)
.sort((a, b) => a.name.localeCompare(b.name));
}, [items]); // Only recalculate when items change
return <Table data={sortedAndFiltered} />;
}
// ❌ Don't use for simple calculations
function Total({ price, quantity }: Props) {
// This is overkill - simple multiplication is fast
const total = useMemo(() => price * quantity, [price, quantity]);
return <div>{total}</div>;
}Examples of when to use useMemo:
- Array operations on large datasets (>1000 items)
- Complex data transformations
- Expensive regex operations
- Creating stable object/array references for dependencies
Don't use for:
- Simple math operations
- String concatenation
- Object property access
- Renders that are already fast
3. useCallback - Stable Function References
Use when: Passing callbacks to memoized children
// ✅ Correct usage - prevent child re-render
function Parent() {
const [count, setCount] = useState(0);
const [filter, setFilter] = useState('');
// MemoizedList only re-renders when handleItemClick changes
const handleItemClick = useCallback((id: string) => {
console.log('Clicked', id);
}, []); // Empty deps - function never changes
return (
<div>
<input value={filter} onChange={(e) => setFilter(e.target.value)} />
<button onClick={() => setCount(count + 1)}>{count}</button>
<MemoizedList onItemClick={handleItemClick} />
</div>
);
}
const MemoizedList = memo(({ onItemClick }: Props) => {
// Expensive render
return <ExpensiveItemList onClick={onItemClick} />;
});
// ❌ Wrong - useCallback without memo is useless
function WrongExample() {
const handleClick = useCallback(() => {
console.log('clicked');
}, []);
// UnmemoizedButton will re-render anyway
return <UnmemoizedButton onClick={handleClick} />;
}Rule: Only use useCallback if: 1. The child component is memoized 2. AND the function is passed as a prop 3. AND the child is expensive to render
4. Key Props - List Performance
Critical for list rendering performance:
// ✅ Stable, unique ID
{items.map(item => (
<Item key={item.id} {...item} />
))}
// ⚠️ Index is OK for static lists only
{staticItems.map((item, index) => (
<StaticItem key={index} {...item} />
))}
// ❌ Never use index for dynamic lists
{dynamicItems.map((item, index) => (
<DynamicItem key={index} {...item} /> // Causes re-render issues
))}
// ❌ Random keys cause re-creation
{items.map(item => (
<Item key={Math.random()} {...item} /> // Every render is new component
))}Why stable keys matter:
- React uses keys to track component identity
- Changing key = unmount + remount (expensive)
- Stable keys allow React to reuse DOM nodes
5. Code Splitting - Reduce Bundle Size
Use React.lazy for route-based splitting:
import { lazy, Suspense } from 'react';
// ✅ Lazy load heavy components
const Dashboard = lazy(() => import('./Dashboard'));
const Settings = lazy(() => import('./Settings'));
const Analytics = lazy(() => import('./Analytics'));
function App() {
return (
<Suspense fallback={<LoadingSpinner />}>
<Routes>
<Route path="/dashboard" element={<Dashboard />} />
<Route path="/settings" element={<Settings />} />
<Route path="/analytics" element={<Analytics />} />
</Routes>
</Suspense>
);
}Named imports optimization:
// ❌ Imports entire library
import _ from 'lodash';
// ✅ Import only what you need
import debounce from 'lodash/debounce';6. Virtualization - Handle Large Lists
Use virtual scrolling for 100+ items:
import { useVirtualizer } from '@tanstack/react-virtual';
import { useRef } from 'react';
// ✅ Only render visible items
function VirtualList({ items }: { items: Item[] }) {
const parentRef = useRef<HTMLDivElement>(null);
const virtualizer = useVirtualizer({
count: items.length,
getScrollElement: () => parentRef.current,
estimateSize: () => 50, // Estimated row height
overscan: 5, // Render extra items for smooth scrolling
});
return (
<div ref={parentRef} style={{ height: '400px', overflow: 'auto' }}>
<div style={{ height: `${virtualizer.getTotalSize()}px` }}>
{virtualizer.getVirtualItems().map(virtualRow => (
<div
key={virtualRow.key}
style={{
position: 'absolute',
top: 0,
left: 0,
width: '100%',
height: `${virtualRow.size}px`,
transform: `translateY(${virtualRow.start}px)`,
}}
>
<Item data={items[virtualRow.index]} />
</div>
))}
</div>
</div>
);
}When to virtualize:
- Lists with 100+ items
- Each item is non-trivial to render
- Users scroll through the list
7. useTransition - Non-Urgent Updates (React 18+)
Use for: Updates that can be deferred (filtering, search)
import { useState, useTransition } from 'react';
// ✅ Keep UI responsive during heavy updates
function SearchableList({ items }: { items: Item[] }) {
const [query, setQuery] = useState('');
const [filteredItems, setFilteredItems] = useState(items);
const [isPending, startTransition] = useTransition();
const handleSearch = (value: string) => {
setQuery(value); // Urgent: update input immediately
// Non-urgent: defer expensive filtering
startTransition(() => {
const filtered = items.filter(item =>
item.name.toLowerCase().includes(value.toLowerCase())
);
setFilteredItems(filtered);
});
};
return (
<div>
<input
value={query}
onChange={(e) => handleSearch(e.target.value)}
placeholder="Search..."
/>
{isPending && <span>Filtering...</span>}
<List items={filteredItems} />
</div>
);
}Common Performance Patterns
Pattern 1: Debounce Expensive Operations
import { useCallback, useEffect, useState } from 'react';
import debounce from 'lodash/debounce';
function SearchBox() {
const [query, setQuery] = useState('');
const [results, setResults] = useState([]);
// Debounce API call
const debouncedSearch = useCallback(
debounce(async (searchQuery: string) => {
const data = await api.search(searchQuery);
setResults(data);
}, 300),
[]
);
useEffect(() => {
if (query) {
debouncedSearch(query);
}
}, [query, debouncedSearch]);
return (
<div>
<input
value={query}
onChange={(e) => setQuery(e.target.value)}
/>
<ResultList results={results} />
</div>
);
}Pattern 2: Separate Fast and Slow Components
// ✅ Isolate fast-changing state
function FastCounter() {
const [count, setCount] = useState(0);
// Fast updates don't affect ExpensiveComponent
return (
<div>
<button onClick={() => setCount(c => c + 1)}>{count}</button>
<ExpensiveComponent />
</div>
);
}
// ❌ Don't mix fast and slow state
function SlowCounter() {
const [count, setCount] = useState(0);
const [data, setData] = useState(heavyData);
// Every count update re-renders heavyData
return (
<div>
<button onClick={() => setCount(c => c + 1)}>{count}</button>
<ExpensiveChart data={data} />
</div>
);
}Pattern 3: Component Composition Over Props
// ✅ Use composition to avoid re-renders
function Layout({ children }: { children: ReactNode }) {
const [count, setCount] = useState(0);
return (
<div>
<button onClick={() => setCount(c => c + 1)}>{count}</button>
{children} {/* children don't re-render when count changes */}
</div>
);
}
// Usage
<Layout>
<ExpensiveComponent /> {/* Won't re-render */}
</Layout>
// ❌ Props-based approach causes re-render
function LayoutWithProps({ content }: { content: ReactNode }) {
const [count, setCount] = useState(0);
return (
<div>
<button onClick={() => setCount(c => c + 1)}>{count}</button>
{content} {/* Will re-render */}
</div>
);
}Performance Checklist
Before Optimization
- [ ] Measured with React DevTools Profiler
- [ ] Identified specific slow components
- [ ] Confirmed user-visible performance issue
- [ ] Checked why components re-render (props/state/context)
Quick Wins
- [ ] Used proper key props (stable, unique)
- [ ] Avoided creating functions/objects in render
- [ ] Extracted expensive operations outside component
- [ ] Split large components into smaller ones
If Still Slow
- [ ] Applied
memoto expensive components - [ ] Used
useMemofor expensive calculations - [ ] Used
useCallbackfor callbacks to memoized children - [ ] Considered virtualization for long lists (100+)
- [ ] Implemented code splitting for large bundles
For Large Lists
- [ ] Using stable, unique keys
- [ ] Considered virtualization (react-virtual)
- [ ] Avoided inline styles/functions
- [ ] Used
memofor list items
Advanced
- [ ] Used
useTransitionfor non-urgent updates - [ ] Implemented debouncing for expensive operations
- [ ] Used web workers for CPU-intensive tasks
- [ ] Optimized images (lazy loading, proper sizes)
Anti-Patterns
❌ Memoizing everything:
// Overkill - creates more overhead
const MemoButton = memo(({ onClick }: Props) => (
<button onClick={onClick}>Click</button>
));❌ useMemo for simple operations:
// Unnecessary - math is fast
const double = useMemo(() => count * 2, [count]);❌ Creating functions in render:
// ❌ New function every render
<button onClick={() => handleClick(item.id)}>Click</button>
// ✅ Better - use callback
const handleClick = (id: string) => console.log(id);
<button onClick={() => handleClick(item.id)}>Click</button>
// ✅ Best for list items - use data attributes
<button data-id={item.id} onClick={handleClick}>Click</button>❌ Inline object/array props:
// ❌ New object every render
<Component style={{ margin: 10 }} items={[1, 2, 3]} />
// ✅ Define outside render
const style = { margin: 10 };
const items = [1, 2, 3];
<Component style={style} items={items} />Debugging Performance Issues
1. Use React DevTools Profiler
- Flame graph shows component hierarchy and render times
- Ranked view shows slowest components
- Look for "Committed at" times >16ms
2. Check Why Components Render
// Add this hook to debug re-renders
function useWhyDidYouUpdate(name: string, props: any) {
const previousProps = useRef<any>();
useEffect(() => {
if (previousProps.current) {
const allKeys = Object.keys({ ...previousProps.current, ...props });
const changedProps: any = {};
allKeys.forEach(key => {
if (previousProps.current[key] !== props[key]) {
changedProps[key] = {
from: previousProps.current[key],
to: props[key]
};
}
});
if (Object.keys(changedProps).length > 0) {
console.log('[why-did-you-update]', name, changedProps);
}
}
previousProps.current = props;
});
}
// Usage
function MyComponent(props: Props) {
useWhyDidYouUpdate('MyComponent', props);
// component logic
}3. Measure Render Time
function MeasuredComponent() {
useEffect(() => {
const start = performance.now();
return () => {
const end = performance.now();
console.log(`Render took ${end - start}ms`);
};
});
return <div>Content</div>;
}Performance Budget
Target metrics for production:
- Initial load: <3 seconds
- Time to Interactive: <5 seconds
- Component render: <16ms (60fps)
- List scrolling: Smooth at 60fps
- User interactions: Response <100ms
Remember: Only optimize what matters. User-perceived performance > micro-optimizations.
State Management Patterns
Complete guide to choosing and implementing state management in React.
Pattern Selection Matrix
| Scenario | Solution | When to Use |
|---|---|---|
| Single component state | useState | Default choice, isolated state |
| Derived state | No state, calculate in render | Value computed from props/state |
| 2-3 related components | Lift state + props | Parent manages, children consume |
| Deep component tree | Context API | Theme, auth, i18n, settings |
| Complex app state | Zustand | Multiple features sharing state |
| Enterprise scale | Redux Toolkit | Need devtools, strict patterns |
| Server data | TanStack Query | API calls, caching, mutations |
| Form state | React Hook Form | 5+ fields, validation |
| URL state | React Router | Shareable/bookmarkable state |
Pattern 1: Local State (useState)
Use for: Component-specific state, toggles, form inputs, UI state
// ✅ Simple counter
function Counter() {
const [count, setCount] = useState(0);
const increment = () => setCount(c => c + 1); // Use functional update
return <button onClick={increment}>{count}</button>;
}
// ✅ Toggle state
function Accordion() {
const [isOpen, setIsOpen] = useState(false);
return (
<div>
<button onClick={() => setIsOpen(!isOpen)}>Toggle</button>
{isOpen && <Content />}
</div>
);
}
// ✅ Input state
function SearchBox() {
const [query, setQuery] = useState('');
return (
<input
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder="Search..."
/>
);
}Rules:
- Always use functional updates when new state depends on old:
setCount(c => c + 1) - Initialize with proper type:
useState<string | null>(null) - Don't store derived values - calculate in render
Pattern 2: Lifted State
Use for: 2-3 closely related components sharing state
// ✅ Parent manages shared state
function TodoApp() {
const [todos, setTodos] = useState<Todo[]>([]);
const addTodo = (text: string) => {
setTodos([...todos, { id: crypto.randomUUID(), text, done: false }]);
};
const toggleTodo = (id: string) => {
setTodos(todos.map(todo =>
todo.id === id ? { ...todo, done: !todo.done } : todo
));
};
return (
<div>
<TodoInput onAdd={addTodo} />
<TodoList todos={todos} onToggle={toggleTodo} />
<TodoStats count={todos.length} />
</div>
);
}
// Children are stateless
function TodoInput({ onAdd }: { onAdd: (text: string) => void }) {
const [text, setText] = useState('');
const handleSubmit = (e: FormEvent) => {
e.preventDefault();
if (text.trim()) {
onAdd(text);
setText('');
}
};
return (
<form onSubmit={handleSubmit}>
<input value={text} onChange={(e) => setText(e.target.value)} />
<button type="submit">Add</button>
</form>
);
}When to stop lifting:
- More than 3 levels of prop drilling → Use Context
- Props becoming complex → Consider component composition
- Multiple unrelated features → Split into separate state
Pattern 3: Context API
Use for: App-wide or feature-wide state (theme, auth, settings)
// ✅ Theme context
interface ThemeContextType {
theme: 'light' | 'dark';
toggleTheme: () => void;
}
const ThemeContext = createContext<ThemeContextType | undefined>(undefined);
function ThemeProvider({ children }: { children: 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 consuming context
function useTheme() {
const context = useContext(ThemeContext);
if (!context) {
throw new Error('useTheme must be used within ThemeProvider');
}
return context;
}
// Usage
function Button() {
const { theme, toggleTheme } = useTheme();
return (
<button
className={theme === 'dark' ? 'btn-dark' : 'btn-light'}
onClick={toggleTheme}
>
Toggle Theme
</button>
);
}Context optimization - split by update frequency:
// ❌ Don't put everything in one context
interface AppContextType {
user: User; // Changes rarely
theme: Theme; // Changes occasionally
notifications: Msg[]; // Changes frequently
}
// ✅ Split by update frequency
const UserContext = createContext<User>(null);
const ThemeContext = createContext<Theme>('light');
const NotificationsContext = createContext<Msg[]>([]);Avoid context for:
- Frequently updating values (causes all consumers to re-render)
- Simple prop passing (2 levels deep is fine)
- Component-specific state
Pattern 4: Zustand (Recommended for Global State)
Use for: Multi-feature apps, complex state, need for middleware
// ✅ Simple store
import { create } from 'zustand';
interface CartStore {
items: CartItem[];
addItem: (item: CartItem) => void;
removeItem: (id: string) => void;
total: number;
}
const useCartStore = create<CartStore>((set, get) => ({
items: [],
addItem: (item) => set((state) => ({
items: [...state.items, item]
})),
removeItem: (id) => set((state) => ({
items: state.items.filter(item => item.id !== id)
})),
get total() {
return get().items.reduce((sum, item) => sum + item.price, 0);
}
}));
// Usage - component only re-renders when selected values change
function Cart() {
const items = useCartStore(state => state.items);
const addItem = useCartStore(state => state.addItem);
const total = useCartStore(state => state.total);
return (
<div>
<h2>Total: ${total}</h2>
<CartItemList items={items} />
<button onClick={() => addItem(newItem)}>Add Item</button>
</div>
);
}Zustand with slices (for large apps):
// Split store into feature slices
const createAuthSlice = (set) => ({
user: null,
login: async (credentials) => {
const user = await api.login(credentials);
set({ user });
},
logout: () => set({ user: null })
});
const createCartSlice = (set) => ({
items: [],
addItem: (item) => set((state) => ({
items: [...state.items, item]
}))
});
const useStore = create((set, get) => ({
...createAuthSlice(set, get),
...createCartSlice(set, get)
}));Pattern 5: TanStack Query (Server State)
Use for: API data, caching, background updates, mutations
// ✅ Data fetching
function UserProfile({ userId }: { userId: string }) {
const { data, isLoading, error, refetch } = useQuery({
queryKey: ['user', userId],
queryFn: () => fetchUser(userId),
staleTime: 5 * 60 * 1000, // Consider fresh for 5 min
});
if (isLoading) return <Spinner />;
if (error) return <Error error={error} retry={refetch} />;
return <div>{data.name}</div>;
}
// ✅ Mutations
function UpdateProfile() {
const queryClient = useQueryClient();
const mutation = useMutation({
mutationFn: (data: UserData) => updateUser(data),
onSuccess: () => {
// Invalidate and refetch
queryClient.invalidateQueries({ queryKey: ['user'] });
},
});
return (
<form onSubmit={(e) => {
e.preventDefault();
mutation.mutate(formData);
}}>
{/* form fields */}
<button disabled={mutation.isPending}>
{mutation.isPending ? 'Saving...' : 'Save'}
</button>
</form>
);
}Don't use TanStack Query for:
- Local UI state
- Form state (use local state or React Hook Form)
- Non-server data
Pattern 6: React Hook Form
Use for: Forms with 5+ fields, complex validation, performance-critical forms
import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { z } from 'zod';
// ✅ Form with validation
const schema = z.object({
email: z.string().email(),
password: z.string().min(8),
age: z.number().min(18),
});
type FormData = z.infer<typeof schema>;
function SignupForm() {
const {
register,
handleSubmit,
formState: { errors, isSubmitting }
} = useForm<FormData>({
resolver: zodResolver(schema),
});
const onSubmit = async (data: FormData) => {
await api.signup(data);
};
return (
<form onSubmit={handleSubmit(onSubmit)}>
<input {...register('email')} />
{errors.email && <span>{errors.email.message}</span>}
<input type="password" {...register('password')} />
{errors.password && <span>{errors.password.message}</span>}
<input type="number" {...register('age', { valueAsNumber: true })} />
{errors.age && <span>{errors.age.message}</span>}
<button disabled={isSubmitting}>Submit</button>
</form>
);
}Pattern 7: URL State (React Router)
Use for: Filters, search, pagination, tabs - anything shareable via URL
import { useSearchParams } from 'react-router-dom';
// ✅ URL-based filters
function ProductList() {
const [searchParams, setSearchParams] = useSearchParams();
const category = searchParams.get('category') || 'all';
const sort = searchParams.get('sort') || 'newest';
const updateFilter = (key: string, value: string) => {
setSearchParams(params => {
params.set(key, value);
return params;
});
};
return (
<div>
<select
value={category}
onChange={(e) => updateFilter('category', e.target.value)}
>
<option value="all">All</option>
<option value="electronics">Electronics</option>
</select>
<ProductGrid category={category} sort={sort} />
</div>
);
}Anti-Patterns
❌ Storing derived values in state:
// Wrong
const [total, setTotal] = useState(0);
useEffect(() => {
setTotal(items.reduce((sum, item) => sum + item.price, 0));
}, [items]);
// Correct - calculate in render
const total = items.reduce((sum, item) => sum + item.price, 0);❌ Using Context for frequently changing values:
// Wrong - causes all consumers to re-render on every change
const NotificationContext = createContext(notifications);
// Better - use Zustand or component state + props
const useNotifications = create(set => ({
notifications: [],
add: (n) => set(state => ({ notifications: [...state.notifications, n] }))
}));❌ Multiple useState for related values:
// Wrong
const [name, setName] = useState('');
const [email, setEmail] = useState('');
const [age, setAge] = useState(0);
// Better - use single state object
const [form, setForm] = useState({ name: '', email: '', age: 0 });Decision Checklist
Before adding state management, ask:
1. Can I derive this value? → Don't store it, calculate it 2. Is this only used here? → useState 3. Shared by 2-3 components? → Lift state 4. App-wide, changes rarely? → Context 5. Complex features, need middleware? → Zustand 6. Server data? → TanStack Query 7. Complex form? → React Hook Form 8. Need to share via URL? → useSearchParams
Start simple, add complexity only when needed.
React Testing Guide
Practical patterns for testing React components with React Testing Library and Vitest/Jest.
Testing Philosophy
Test from the user's perspective:
- Test what the user sees and does
- Avoid testing implementation details
- Focus on behavior, not internals
Priority: 1. User interactions and flows 2. Rendering with different props/state 3. Edge cases and error states 4. Integration tests over unit tests
Setup
Vitest + React Testing Library
// vitest.config.ts
import { defineConfig } from 'vitest/config';
import react from '@vitejs/plugin-react';
export default defineConfig({
plugins: [react()],
test: {
environment: 'jsdom',
setupFiles: ['./src/test/setup.ts'],
globals: true,
},
});// src/test/setup.ts
import '@testing-library/jest-dom';
import { cleanup } from '@testing-library/react';
import { afterEach } from 'vitest';
afterEach(() => {
cleanup();
});Basic Component Testing
Simple Component
// Button.tsx
interface ButtonProps {
onClick: () => void;
children: ReactNode;
disabled?: boolean;
}
function Button({ onClick, children, disabled = false }: ButtonProps) {
return (
<button onClick={onClick} disabled={disabled}>
{children}
</button>
);
}
// Button.test.tsx
import { render, screen, fireEvent } from '@testing-library/react';
import { describe, it, expect, vi } from 'vitest';
import Button from './Button';
describe('Button', () => {
it('renders children', () => {
render(<Button onClick={() => {}}>Click me</Button>);
expect(screen.getByText('Click me')).toBeInTheDocument();
});
it('calls onClick when clicked', () => {
const handleClick = vi.fn();
render(<Button onClick={handleClick}>Click me</Button>);
fireEvent.click(screen.getByText('Click me'));
expect(handleClick).toHaveBeenCalledTimes(1);
});
it('does not call onClick when disabled', () => {
const handleClick = vi.fn();
render(<Button onClick={handleClick} disabled>Click me</Button>);
const button = screen.getByText('Click me');
fireEvent.click(button);
expect(handleClick).not.toHaveBeenCalled();
expect(button).toBeDisabled();
});
});Component with State
// Counter.tsx
function Counter() {
const [count, setCount] = useState(0);
return (
<div>
<span data-testid="count">{count}</span>
<button onClick={() => setCount(c => c + 1)}>Increment</button>
<button onClick={() => setCount(0)}>Reset</button>
</div>
);
}
// Counter.test.tsx
describe('Counter', () => {
it('starts at zero', () => {
render(<Counter />);
expect(screen.getByTestId('count')).toHaveTextContent('0');
});
it('increments count', () => {
render(<Counter />);
fireEvent.click(screen.getByText('Increment'));
expect(screen.getByTestId('count')).toHaveTextContent('1');
});
it('resets count', () => {
render(<Counter />);
fireEvent.click(screen.getByText('Increment'));
fireEvent.click(screen.getByText('Increment'));
fireEvent.click(screen.getByText('Reset'));
expect(screen.getByTestId('count')).toHaveTextContent('0');
});
});Querying Elements
Query Priority
1. Accessible queries (preferred):
getByRole- Most robustgetByLabelText- For form fieldsgetByPlaceholderText- For inputsgetByText- For non-interactive contentgetByDisplayValue- For form elements
2. Semantic queries:
getByAltText- For imagesgetByTitle- For title attribute
3. Test IDs (last resort):
getByTestId- When nothing else works
// ✅ Best - use accessible queries
screen.getByRole('button', { name: 'Submit' });
screen.getByLabelText('Email');
screen.getByPlaceholderText('Enter your name');
// ⚠️ OK - semantic
screen.getByAltText('Profile picture');
// ❌ Avoid - test IDs
screen.getByTestId('submit-button');Query Variants
// getBy* - Throws if not found
const button = screen.getByRole('button');
// queryBy* - Returns null if not found
const button = screen.queryByRole('button');
if (button) { ... }
// findBy* - Async, waits for element
const button = await screen.findByRole('button');
// *AllBy* - Returns array
const buttons = screen.getAllByRole('button');User Interactions
userEvent vs fireEvent
import userEvent from '@testing-library/user-event';
// ✅ Prefer userEvent (simulates real user behavior)
describe('Form', () => {
it('handles user input', async () => {
const user = userEvent.setup();
render(<Form />);
const input = screen.getByLabelText('Email');
await user.type(input, 'test@example.com');
await user.click(screen.getByRole('button', { name: 'Submit' }));
expect(screen.getByText('Success')).toBeInTheDocument();
});
});
// ⚠️ fireEvent is simpler but less realistic
describe('Form', () => {
it('handles input', () => {
render(<Form />);
const input = screen.getByLabelText('Email');
fireEvent.change(input, { target: { value: 'test@example.com' } });
fireEvent.click(screen.getByRole('button', { name: 'Submit' }));
expect(screen.getByText('Success')).toBeInTheDocument();
});
});Common Interactions
// Typing
await user.type(input, 'Hello');
// Clicking
await user.click(button);
// Double click
await user.dblClick(button);
// Select from dropdown
await user.selectOptions(select, 'option1');
// Upload file
const file = new File(['hello'], 'hello.png', { type: 'image/png' });
await user.upload(input, file);
// Keyboard
await user.keyboard('{Enter}');
await user.keyboard('{Escape}');Async Testing
Waiting for Elements
// ✅ Wait for element to appear
it('shows success message', async () => {
render(<AsyncComponent />);
const message = await screen.findByText('Success', {}, { timeout: 3000 });
expect(message).toBeInTheDocument();
});
// ✅ Wait for element to disappear
it('removes loading spinner', async () => {
render(<AsyncComponent />);
await waitForElementToBeRemoved(() => screen.queryByText('Loading...'));
expect(screen.queryByText('Loading...')).not.toBeInTheDocument();
});
// ✅ waitFor for complex conditions
it('displays data', async () => {
render(<DataComponent />);
await waitFor(() => {
expect(screen.getByText('Data loaded')).toBeInTheDocument();
}, { timeout: 3000 });
});Mocking API Calls
import { vi } from 'vitest';
// Mock fetch
global.fetch = vi.fn();
describe('UserProfile', () => {
beforeEach(() => {
vi.clearAllMocks();
});
it('fetches and displays user', async () => {
const mockUser = { id: '1', name: 'John' };
(global.fetch as any).mockResolvedValueOnce({
ok: true,
json: async () => mockUser,
});
render(<UserProfile userId="1" />);
expect(await screen.findByText('John')).toBeInTheDocument();
expect(global.fetch).toHaveBeenCalledWith('/api/users/1');
});
it('handles fetch error', async () => {
(global.fetch as any).mockRejectedValueOnce(new Error('Failed'));
render(<UserProfile userId="1" />);
expect(await screen.findByText('Error loading user')).toBeInTheDocument();
});
});Testing with Context
// TestWrapper.tsx
function TestWrapper({ children }: PropsWithChildren) {
return (
<AuthProvider>
<ThemeProvider>
{children}
</ThemeProvider>
</AuthProvider>
);
}
// Custom render function
function renderWithContext(ui: ReactElement) {
return render(ui, { wrapper: TestWrapper });
}
// Usage
describe('ProtectedComponent', () => {
it('shows content when authenticated', () => {
renderWithContext(<ProtectedComponent />);
expect(screen.getByText('Protected content')).toBeInTheDocument();
});
});Testing Custom Hooks
import { renderHook, waitFor } from '@testing-library/react';
// useCounter.ts
function useCounter(initialValue = 0) {
const [count, setCount] = useState(initialValue);
const increment = () => setCount(c => c + 1);
const decrement = () => setCount(c => c - 1);
const reset = () => setCount(initialValue);
return { count, increment, decrement, reset };
}
// useCounter.test.ts
describe('useCounter', () => {
it('initializes with default value', () => {
const { result } = renderHook(() => useCounter());
expect(result.current.count).toBe(0);
});
it('increments count', () => {
const { result } = renderHook(() => useCounter());
act(() => {
result.current.increment();
});
expect(result.current.count).toBe(1);
});
it('accepts initial value', () => {
const { result } = renderHook(() => useCounter(10));
expect(result.current.count).toBe(10);
});
it('resets to initial value', () => {
const { result } = renderHook(() => useCounter(5));
act(() => {
result.current.increment();
result.current.increment();
result.current.reset();
});
expect(result.current.count).toBe(5);
});
});
// Testing async hook
describe('useFetch', () => {
it('fetches data successfully', async () => {
global.fetch = vi.fn().mockResolvedValue({
ok: true,
json: async () => ({ data: 'test' }),
});
const { result } = renderHook(() => useFetch('/api/data'));
expect(result.current.loading).toBe(true);
await waitFor(() => {
expect(result.current.loading).toBe(false);
});
expect(result.current.data).toEqual({ data: 'test' });
expect(result.current.error).toBeNull();
});
});Testing Forms
// LoginForm.tsx
function LoginForm({ onSubmit }: { onSubmit: (data: FormData) => void }) {
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
const [errors, setErrors] = useState<Record<string, string>>({});
const handleSubmit = (e: FormEvent) => {
e.preventDefault();
const newErrors: Record<string, string> = {};
if (!email) newErrors.email = 'Email is required';
if (!password) newErrors.password = 'Password is required';
if (Object.keys(newErrors).length > 0) {
setErrors(newErrors);
return;
}
onSubmit({ email, password });
};
return (
<form onSubmit={handleSubmit}>
<div>
<label htmlFor="email">Email</label>
<input
id="email"
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
/>
{errors.email && <span role="alert">{errors.email}</span>}
</div>
<div>
<label htmlFor="password">Password</label>
<input
id="password"
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
/>
{errors.password && <span role="alert">{errors.password}</span>}
</div>
<button type="submit">Login</button>
</form>
);
}
// LoginForm.test.tsx
describe('LoginForm', () => {
it('submits valid form', async () => {
const user = userEvent.setup();
const handleSubmit = vi.fn();
render(<LoginForm onSubmit={handleSubmit} />);
await user.type(screen.getByLabelText('Email'), 'test@example.com');
await user.type(screen.getByLabelText('Password'), 'password123');
await user.click(screen.getByRole('button', { name: 'Login' }));
expect(handleSubmit).toHaveBeenCalledWith({
email: 'test@example.com',
password: 'password123',
});
});
it('shows validation errors', async () => {
const user = userEvent.setup();
const handleSubmit = vi.fn();
render(<LoginForm onSubmit={handleSubmit} />);
await user.click(screen.getByRole('button', { name: 'Login' }));
expect(screen.getByText('Email is required')).toBeInTheDocument();
expect(screen.getByText('Password is required')).toBeInTheDocument();
expect(handleSubmit).not.toHaveBeenCalled();
});
it('clears errors on input', async () => {
const user = userEvent.setup();
render(<LoginForm onSubmit={() => {}} />);
// Trigger errors
await user.click(screen.getByRole('button', { name: 'Login' }));
expect(screen.getByText('Email is required')).toBeInTheDocument();
// Type in email - error should clear
await user.type(screen.getByLabelText('Email'), 'test@example.com');
// Submit again to clear password error
await user.click(screen.getByRole('button', { name: 'Login' }));
expect(screen.queryByText('Email is required')).not.toBeInTheDocument();
});
});Mocking Patterns
Mock Components
// Mock child components to test parent in isolation
vi.mock('./ExpensiveComponent', () => ({
default: () => <div>Mocked Component</div>,
}));
describe('ParentComponent', () => {
it('renders children', () => {
render(<ParentComponent />);
expect(screen.getByText('Mocked Component')).toBeInTheDocument();
});
});Mock Modules
// Mock external API
vi.mock('../api', () => ({
fetchUser: vi.fn(),
updateUser: vi.fn(),
}));
import { fetchUser } from '../api';
describe('UserProfile', () => {
it('fetches user data', async () => {
(fetchUser as any).mockResolvedValue({ id: '1', name: 'John' });
render(<UserProfile userId="1" />);
expect(await screen.findByText('John')).toBeInTheDocument();
});
});Mock Timers
import { vi } from 'vitest';
describe('Timer', () => {
beforeEach(() => {
vi.useFakeTimers();
});
afterEach(() => {
vi.useRealTimers();
});
it('updates after delay', () => {
render(<Timer />);
expect(screen.getByText('0')).toBeInTheDocument();
vi.advanceTimersByTime(1000);
expect(screen.getByText('1')).toBeInTheDocument();
});
});Best Practices
1. Test Behavior, Not Implementation
// ❌ Testing implementation details
it('sets state correctly', () => {
const { result } = renderHook(() => useState(0));
act(() => result.current[1](1));
expect(result.current[0]).toBe(1);
});
// ✅ Testing behavior
it('increments counter', () => {
render(<Counter />);
fireEvent.click(screen.getByText('Increment'));
expect(screen.getByTestId('count')).toHaveTextContent('1');
});2. Use Accessible Queries
// ❌ Brittle - relies on class/structure
screen.getByClassName('submit-button');
// ✅ Robust - uses semantic role
screen.getByRole('button', { name: 'Submit' });3. Avoid Snapshot Testing for Components
// ❌ Brittle, hard to review
expect(component).toMatchSnapshot();
// ✅ Test specific behavior
expect(screen.getByText('Welcome')).toBeInTheDocument();
expect(screen.getByRole('button')).toBeEnabled();4. Clean Up After Each Test
// Automatically handled by cleanup()
import { cleanup } from '@testing-library/react';
import { afterEach } from 'vitest';
afterEach(() => {
cleanup();
});5. Group Related Tests
describe('LoginForm', () => {
describe('validation', () => {
it('validates email format', () => { ... });
it('validates password length', () => { ... });
});
describe('submission', () => {
it('submits valid form', () => { ... });
it('prevents double submission', () => { ... });
});
});Common Testing Patterns
Loading States
it('shows loading state', () => {
render(<AsyncComponent />);
expect(screen.getByText('Loading...')).toBeInTheDocument();
});
it('hides loading after data loads', async () => {
render(<AsyncComponent />);
await waitForElementToBeRemoved(() => screen.queryByText('Loading...'));
expect(screen.getByText('Data loaded')).toBeInTheDocument();
});Error States
it('displays error message', async () => {
global.fetch = vi.fn().mockRejectedValue(new Error('Failed'));
render(<Component />);
expect(await screen.findByText('Error: Failed')).toBeInTheDocument();
});Empty States
it('shows empty state when no data', () => {
render(<List items={[]} />);
expect(screen.getByText('No items found')).toBeInTheDocument();
});Accessibility Testing
import { axe, toHaveNoViolations } from 'jest-axe';
expect.extend(toHaveNoViolations);
it('has no accessibility violations', async () => {
const { container } = render(<Component />);
const results = await axe(container);
expect(results).toHaveNoViolations();
});Test Coverage Goals
- Statements: 80%+
- Branches: 70%+
- Functions: 80%+
- Lines: 80%+
Focus on:
- User-facing features
- Critical business logic
- Error handling
- Edge cases
Skip:
- Third-party libraries
- Configuration files
- Types/interfaces
- Trivial getters/setters
TypeScript Patterns for React
Practical TypeScript patterns for React development.
Component Props Typing
Basic Props
// ✅ Interface for props
interface ButtonProps {
label: string;
onClick: () => void;
disabled?: boolean; // Optional
variant?: 'primary' | 'secondary'; // Union type
}
function Button({ label, onClick, disabled = false, variant = 'primary' }: ButtonProps) {
return (
<button onClick={onClick} disabled={disabled} className={variant}>
{label}
</button>
);
}
// ✅ Type for simple props
type IconProps = {
name: string;
size: number;
color?: string;
};Props with Children
// ✅ ReactNode for any valid React content
interface CardProps {
title: string;
children: ReactNode;
}
function Card({ title, children }: CardProps) {
return (
<div>
<h2>{title}</h2>
{children}
</div>
);
}
// ✅ Specific child type
interface ListProps {
children: ReactElement<ItemProps> | ReactElement<ItemProps>[];
}
// ✅ Using PropsWithChildren helper
type ContainerProps = PropsWithChildren<{
className?: string;
}>;Event Handlers
// ✅ Common event types
interface FormProps {
onSubmit: (e: FormEvent<HTMLFormElement>) => void;
onChange: (e: ChangeEvent<HTMLInputElement>) => void;
onClick: (e: MouseEvent<HTMLButtonElement>) => void;
onFocus: (e: FocusEvent<HTMLInputElement>) => void;
}
// ✅ Async handlers
interface AsyncButtonProps {
onClick: () => Promise<void>;
}
function AsyncButton({ onClick }: AsyncButtonProps) {
const [loading, setLoading] = useState(false);
const handleClick = async () => {
setLoading(true);
try {
await onClick();
} finally {
setLoading(false);
}
};
return <button onClick={handleClick} disabled={loading}>Click</button>;
}Generic Props
// ✅ Generic component
interface SelectProps<T> {
options: T[];
value: T;
onChange: (value: T) => void;
getLabel: (option: T) => string;
getValue: (option: T) => string;
}
function Select<T>({ options, value, onChange, getLabel, getValue }: SelectProps<T>) {
return (
<select
value={getValue(value)}
onChange={(e) => {
const option = options.find(o => getValue(o) === e.target.value);
if (option) onChange(option);
}}
>
{options.map(option => (
<option key={getValue(option)} value={getValue(option)}>
{getLabel(option)}
</option>
))}
</select>
);
}
// Usage
interface User {
id: string;
name: string;
}
<Select<User>
options={users}
value={selectedUser}
onChange={setSelectedUser}
getLabel={(u) => u.name}
getValue={(u) => u.id}
/>State Typing
useState
// ✅ Type inference (preferred)
const [count, setCount] = useState(0); // number
const [name, setName] = useState(''); // string
const [isOpen, setIsOpen] = useState(false); // boolean
// ✅ Explicit typing for complex types
interface User {
id: string;
name: string;
email: string;
}
const [user, setUser] = useState<User | null>(null);
// ✅ Union types
type Status = 'idle' | 'loading' | 'success' | 'error';
const [status, setStatus] = useState<Status>('idle');
// ✅ Array state
const [items, setItems] = useState<Item[]>([]);
// ✅ Object state
interface FormData {
email: string;
password: string;
rememberMe: boolean;
}
const [form, setForm] = useState<FormData>({
email: '',
password: '',
rememberMe: false,
});useReducer
// ✅ Typed reducer
interface State {
count: number;
error: string | null;
}
type Action =
| { type: 'INCREMENT' }
| { type: 'DECREMENT' }
| { type: 'SET_ERROR'; payload: string }
| { type: 'RESET' };
function reducer(state: State, action: Action): State {
switch (action.type) {
case 'INCREMENT':
return { ...state, count: state.count + 1 };
case 'DECREMENT':
return { ...state, count: state.count - 1 };
case 'SET_ERROR':
return { ...state, error: action.payload };
case 'RESET':
return { count: 0, error: null };
}
}
function Counter() {
const [state, dispatch] = useReducer(reducer, { count: 0, error: null });
return (
<div>
<button onClick={() => dispatch({ type: 'INCREMENT' })}>+</button>
<span>{state.count}</span>
<button onClick={() => dispatch({ type: 'DECREMENT' })}>-</button>
</div>
);
}useContext
// ✅ Typed context
interface AuthContextType {
user: User | null;
login: (email: string, password: string) => Promise<void>;
logout: () => void;
isAuthenticated: boolean;
}
const AuthContext = createContext<AuthContextType | undefined>(undefined);
function useAuth() {
const context = useContext(AuthContext);
if (!context) {
throw new Error('useAuth must be used within AuthProvider');
}
return context;
}
function AuthProvider({ children }: PropsWithChildren) {
const [user, setUser] = useState<User | null>(null);
const login = async (email: string, password: string) => {
const user = await api.login(email, password);
setUser(user);
};
const logout = () => setUser(null);
const value: AuthContextType = {
user,
login,
logout,
isAuthenticated: !!user,
};
return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>;
}Hooks Typing
useRef
// ✅ DOM element ref
function Input() {
const inputRef = useRef<HTMLInputElement>(null);
useEffect(() => {
inputRef.current?.focus(); // Use optional chaining
}, []);
return <input ref={inputRef} />;
}
// ✅ Mutable value ref
function Timer() {
const intervalRef = useRef<number | null>(null);
useEffect(() => {
intervalRef.current = window.setInterval(() => {
console.log('tick');
}, 1000);
return () => {
if (intervalRef.current) {
clearInterval(intervalRef.current);
}
};
}, []);
return <div>Timer</div>;
}Custom Hooks
// ✅ Typed custom hook with return type
function useLocalStorage<T>(key: string, initialValue: T): [T, (value: T) => void] {
const [storedValue, setStoredValue] = useState<T>(() => {
try {
const item = window.localStorage.getItem(key);
return item ? JSON.parse(item) : initialValue;
} catch {
return initialValue;
}
});
const setValue = (value: T) => {
try {
setStoredValue(value);
window.localStorage.setItem(key, JSON.stringify(value));
} catch (error) {
console.error(error);
}
};
return [storedValue, setValue];
}
// Usage
const [theme, setTheme] = useLocalStorage<'light' | 'dark'>('theme', 'light');
// ✅ Hook with object return
interface UseFetchResult<T> {
data: T | null;
loading: boolean;
error: Error | null;
refetch: () => 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 () => {
try {
setLoading(true);
const response = await fetch(url);
const json = await response.json();
setData(json);
} catch (err) {
setError(err as Error);
} finally {
setLoading(false);
}
}, [url]);
useEffect(() => {
fetchData();
}, [fetchData]);
return { data, loading, error, refetch: fetchData };
}Component Patterns
Discriminated Unions
// ✅ Type-safe variants
type ButtonProps =
| { variant: 'link'; href: string; onClick?: never }
| { variant: 'button'; onClick: () => void; href?: never };
function Button(props: ButtonProps) {
if (props.variant === 'link') {
return <a href={props.href}>Link</a>;
}
return <button onClick={props.onClick}>Button</button>;
}
// Usage - TypeScript enforces correct props
<Button variant="link" href="/home" /> // ✓
<Button variant="button" onClick={handleClick} /> // ✓
<Button variant="link" onClick={handleClick} /> // ✗ ErrorRender Props
// ✅ Typed render prop
interface DataLoaderProps<T> {
url: string;
render: (data: T, loading: boolean, error: Error | null) => ReactNode;
}
function DataLoader<T>({ url, render }: DataLoaderProps<T>) {
const { data, loading, error } = useFetch<T>(url);
return <>{render(data, loading, error)}</>;
}
// Usage
<DataLoader<User>
url="/api/user"
render={(user, loading, error) => {
if (loading) return <Spinner />;
if (error) return <Error error={error} />;
return <div>{user?.name}</div>;
}}
/>Compound Components
// ✅ Typed compound components
interface TabsContextType {
activeTab: string;
setActiveTab: (tab: string) => void;
}
const TabsContext = createContext<TabsContextType | undefined>(undefined);
function useTabs() {
const context = useContext(TabsContext);
if (!context) throw new Error('Must be used within Tabs');
return context;
}
interface TabsProps extends PropsWithChildren {
defaultTab: string;
}
function Tabs({ children, defaultTab }: TabsProps) {
const [activeTab, setActiveTab] = useState(defaultTab);
return (
<TabsContext.Provider value={{ activeTab, setActiveTab }}>
{children}
</TabsContext.Provider>
);
}
interface TabProps extends PropsWithChildren {
value: string;
}
Tabs.Tab = function Tab({ value, children }: TabProps) {
const { activeTab, setActiveTab } = useTabs();
return (
<button
onClick={() => setActiveTab(value)}
className={activeTab === value ? 'active' : ''}
>
{children}
</button>
);
};
Tabs.Panel = function Panel({ value, children }: TabProps) {
const { activeTab } = useTabs();
return activeTab === value ? <div>{children}</div> : null;
};
// Usage
<Tabs defaultTab="home">
<Tabs.Tab value="home">Home</Tabs.Tab>
<Tabs.Tab value="profile">Profile</Tabs.Tab>
<Tabs.Panel value="home">Home content</Tabs.Panel>
<Tabs.Panel value="profile">Profile content</Tabs.Panel>
</Tabs>Utility Types
Useful React Types
// Component props
type ButtonElement = ComponentPropsWithoutRef<'button'>;
type DivElement = ComponentPropsWithRef<'div'>;
// Extending HTML element props
interface CustomButtonProps extends ButtonElement {
variant: 'primary' | 'secondary';
}
// Extract props from component
type InputProps = ComponentProps<typeof Input>;
// Element type
type ElementType = ReactElement<any, any>;Custom Utility Types
// ✅ Make all properties optional except specified ones
type PartialExcept<T, K extends keyof T> = Partial<T> & Pick<T, K>;
interface User {
id: string;
name: string;
email: string;
age: number;
}
// id is required, rest are optional
type UserUpdate = PartialExcept<User, 'id'>;
// ✅ Make specific properties required
type RequireFields<T, K extends keyof T> = T & Required<Pick<T, K>>;
type UserWithEmail = RequireFields<Partial<User>, 'email'>;
// ✅ Omit multiple properties
type OmitMultiple<T, K extends keyof T> = Omit<T, K>;
type UserWithoutSensitiveData = OmitMultiple<User, 'email' | 'age'>;Type Guards
// ✅ Type guard function
function isUser(value: unknown): value is User {
return (
typeof value === 'object' &&
value !== null &&
'id' in value &&
'name' in value
);
}
// Usage
if (isUser(data)) {
console.log(data.name); // TypeScript knows data is User
}
// ✅ Discriminated union guard
type Response =
| { status: 'success'; data: User }
| { status: 'error'; error: string };
function handleResponse(response: Response) {
if (response.status === 'success') {
console.log(response.data); // TypeScript knows this is success case
} else {
console.log(response.error); // TypeScript knows this is error case
}
}Typing Patterns
API Response Typing
// ✅ Generic API response wrapper
interface ApiResponse<T> {
data: T;
status: number;
message?: string;
}
interface PaginatedResponse<T> {
items: T[];
total: number;
page: number;
pageSize: number;
}
// Usage
async function fetchUsers(): Promise<ApiResponse<PaginatedResponse<User>>> {
const response = await fetch('/api/users');
return response.json();
}Form Typing
// ✅ Form data from schema
import { z } from 'zod';
const loginSchema = z.object({
email: z.string().email(),
password: z.string().min(8),
rememberMe: z.boolean(),
});
// Infer TypeScript type from schema
type LoginFormData = z.infer<typeof loginSchema>;
function LoginForm() {
const [form, setForm] = useState<LoginFormData>({
email: '',
password: '',
rememberMe: false,
});
const handleSubmit = (e: FormEvent) => {
e.preventDefault();
const result = loginSchema.safeParse(form);
if (result.success) {
// form is validated
console.log(result.data);
}
};
return <form onSubmit={handleSubmit}>...</form>;
}Best Practices
1. Use interfaces for public APIs, types for internal use
// Public component props
interface Props { ... }
// Internal types
type Status = 'idle' | 'loading';2. Prefer inference over explicit typing
// ✓ Let TypeScript infer
const [count, setCount] = useState(0);
// ✗ Unnecessary
const [count, setCount] = useState<number>(0);3. Use strict mode
// tsconfig.json
{
"compilerOptions": {
"strict": true,
"noUncheckedIndexedAccess": true,
"noImplicitReturns": true
}
}4. Avoid `any`, use `unknown` instead
// ✗ Don't use any
function process(data: any) { ... }
// ✓ Use unknown and type guard
function process(data: unknown) {
if (isValidData(data)) {
// Now TypeScript knows the type
}
}5. Use const assertions for literal types
// ✓ Literal type
const colors = ['red', 'blue', 'green'] as const;
type Color = typeof colors[number]; // 'red' | 'blue' | 'green'