React Component Development
Modern React patterns with TypeScript, hooks, and best practices.
Component Structure
Basic Component Template
import { useState, useCallback, memo } from 'react';
import type { FC, ReactNode } from 'react';
import styles from './Button.module.css';
interface ButtonProps {
/** Button content */
children: ReactNode;
/** Button variant style */
variant?: 'primary' | 'secondary' | 'danger';
/** Size of the button */
size?: 'sm' | 'md' | 'lg';
/** Whether the button is disabled */
disabled?: boolean;
/** Loading state */
loading?: boolean;
/** Click handler */
onClick?: () => void;
/** Additional CSS classes */
className?: string;
}
export const Button: FC<ButtonProps> = memo(({
children,
variant = 'primary',
size = 'md',
disabled = false,
loading = false,
onClick,
className,
}) => {
const handleClick = useCallback(() => {
if (!disabled && !loading && onClick) {
onClick();
}
}, [disabled, loading, onClick]);
return (
<button
type="button"
className={`${styles.button} ${styles[variant]} ${styles[size]} ${className ?? ''}`}
disabled={disabled || loading}
onClick={handleClick}
aria-busy={loading}
>
{loading ? <Spinner size="sm" /> : children}
</button>
);
});
Button.displayName = 'Button';
Hooks Best Practices
useState
// BAD: Multiple related states
const [firstName, setFirstName] = useState('');
const [lastName, setLastName] = useState('');
const [email, setEmail] = useState('');
// GOOD: Group related state
interface FormData {
firstName: string;
lastName: string;
email: string;
}
const [formData, setFormData] = useState<FormData>({
firstName: '',
lastName: '',
email: '',
});
// Update single field
const updateField = (field: keyof FormData, value: string) => {
setFormData(prev => ({ ...prev, [field]: value }));
};
useEffect
// BAD: Missing cleanup
useEffect(() => {
const subscription = api.subscribe(handler);
// Memory leak - no cleanup!
}, []);
// GOOD: Proper cleanup
useEffect(() => {
const subscription = api.subscribe(handler);
return () => subscription.unsubscribe();
}, [handler]);
// BAD: Stale closure
useEffect(() => {
const interval = setInterval(() => {
setCount(count + 1); // count is stale!
}, 1000);
return () => clearInterval(interval);
}, []); // Missing count dependency
// GOOD: Functional update
useEffect(() => {
const interval = setInterval(() => {
setCount(prev => prev + 1); // Always uses latest
}, 1000);
return () => clearInterval(interval);
}, []);
useCallback & useMemo
// useCallback - Memoize functions
const handleSubmit = useCallback(async (data: FormData) => {
await api.submit(data);
onSuccess();
}, [onSuccess]);
// useMemo - Memoize expensive computations
const sortedItems = useMemo(() => {
return [...items].sort((a, b) => a.name.localeCompare(b.name));
}, [items]);
// useMemo - Memoize objects/arrays passed as props
const config = useMemo(() => ({
theme: 'dark',
locale: 'en',
}), []); // Stable reference
// DON'T over-memoize simple values
// BAD
const doubled = useMemo(() => count * 2, [count]);
// GOOD - Simple math doesn't need memoization
const doubled = count * 2;
useRef
// DOM references
const inputRef = useRef<HTMLInputElement>(null);
const focusInput = () => {
inputRef.current?.focus();
};
// Mutable values that don't trigger re-renders
const renderCount = useRef(0);
renderCount.current += 1;
// Previous value pattern
function usePrevious<T>(value: T): T | undefined {
const ref = useRef<T>();
useEffect(() => {
ref.current = value;
});
return ref.current;
}
Custom Hooks
Data Fetching Hook
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);
setError(null);
const response = await fetch(url);
if (!response.ok) throw new Error('Fetch failed');
const json = await response.json();
setData(json);
} catch (err) {
setError(err instanceof Error ? err : new Error('Unknown error'));
} finally {
setLoading(false);
}
}, [url]);
useEffect(() => {
fetchData();
}, [fetchData]);
return { data, loading, error, refetch: fetchData };
}
Form Hook
interface UseFormOptions<T> {
initialValues: T;
validate?: (values: T) => Partial<Record<keyof T, string>>;
onSubmit: (values: T) => void | Promise<void>;
}
function useForm<T extends Record<string, any>>({
initialValues,
validate,
onSubmit,
}: UseFormOptions<T>) {
const [values, setValues] = useState<T>(initialValues);
const [errors, setErrors] = useState<Partial<Record<keyof T, string>>>({});
const [submitting, setSubmitting] = useState(false);
const handleChange = useCallback((field: keyof T, value: any) => {
setValues(prev => ({ ...prev, [field]: value }));
// Clear error on change
setErrors(prev => ({ ...prev, [field]: undefined }));
}, []);
const handleSubmit = useCallback(async (e: React.FormEvent) => {
e.preventDefault();
if (validate) {
const validationErrors = validate(values);
if (Object.keys(validationErrors).length > 0) {
setErrors(validationErrors);
return;
}
}
setSubmitting(true);
try {
await onSubmit(values);
} finally {
setSubmitting(false);
}
}, [values, validate, onSubmit]);
const reset = useCallback(() => {
setValues(initialValues);
setErrors({});
}, [initialValues]);
return { values, errors, submitting, handleChange, handleSubmit, reset };
}
Toggle Hook
function useToggle(initial = false): [boolean, () => void, () => void, () => void] {
const [value, setValue] = useState(initial);
const toggle = useCallback(() => setValue(v => !v), []);
const setTrue = useCallback(() => setValue(true), []);
const setFalse = useCallback(() => setValue(false), []);
return [value, toggle, setTrue, setFalse];
}
// Usage
const [isOpen, toggleOpen, open, close] = useToggle(false);
Component Patterns
Compound Components
interface TabsContextValue {
activeTab: string;
setActiveTab: (id: string) => void;
}
const TabsContext = createContext<TabsContextValue | null>(null);
function Tabs({ children, defaultTab }: { children: ReactNode; defaultTab: string }) {
const [activeTab, setActiveTab] = useState(defaultTab);
return (
<TabsContext.Provider value={{ activeTab, setActiveTab }}>
<div className="tabs">{children}</div>
</TabsContext.Provider>
);
}
function TabList({ children }: { children: ReactNode }) {
return <div role="tablist">{children}</div>;
}
function Tab({ id, children }: { id: string; children: ReactNode }) {
const context = useContext(TabsContext);
if (!context) throw new Error('Tab must be used within Tabs');
return (
<button
role="tab"
aria-selected={context.activeTab === id}
onClick={() => context.setActiveTab(id)}
>
{children}
</button>
);
}
function TabPanel({ id, children }: { id: string; children: ReactNode }) {
const context = useContext(TabsContext);
if (!context) throw new Error('TabPanel must be used within Tabs');
if (context.activeTab !== id) return null;
return <div role="tabpanel">{children}</div>;
}
// Usage
<Tabs defaultTab="tab1">
<TabList>
<Tab id="tab1">Tab 1</Tab>
<Tab id="tab2">Tab 2</Tab>
</TabList>
<TabPanel id="tab1">Content 1</TabPanel>
<TabPanel id="tab2">Content 2</TabPanel>
</Tabs>
Render Props
interface MousePosition {
x: number;
y: number;
}
interface MouseTrackerProps {
children: (position: MousePosition) => ReactNode;
}
function MouseTracker({ children }: MouseTrackerProps) {
const [position, setPosition] = useState({ x: 0, y: 0 });
useEffect(() => {
const handleMove = (e: MouseEvent) => {
setPosition({ x: e.clientX, y: e.clientY });
};
window.addEventListener('mousemove', handleMove);
return () => window.removeEventListener('mousemove', handleMove);
}, []);
return <>{children(position)}</>;
}
// Usage
<MouseTracker>
{({ x, y }) => <div>Mouse: {x}, {y}</div>}
</MouseTracker>
Higher-Order Components
function withLoading<P extends object>(Component: ComponentType<P>) {
return function WithLoadingComponent({
loading,
...props
}: P & { loading: boolean }) {
if (loading) return <Spinner />;
return <Component {...(props as P)} />;
};
}
// Usage
const UserListWithLoading = withLoading(UserList);
<UserListWithLoading loading={isLoading} users={users} />
Accessibility (a11y)
ARIA Attributes
// Button with loading state
<button
aria-busy={loading}
aria-disabled={disabled}
aria-describedby={hasError ? 'error-message' : undefined}
>
{loading ? 'Loading...' : 'Submit'}
</button>
// Modal dialog
<div
role="dialog"
aria-modal="true"
aria-labelledby="modal-title"
aria-describedby="modal-description"
>
<h2 id="modal-title">Confirm Action</h2>
<p id="modal-description">Are you sure?</p>
</div>
// Live region for dynamic content
<div aria-live="polite" aria-atomic="true">
{message}
</div>
Keyboard Navigation
function Menu({ items }: { items: MenuItem[] }) {
const [focusIndex, setFocusIndex] = useState(0);
const handleKeyDown = (e: React.KeyboardEvent) => {
switch (e.key) {
case 'ArrowDown':
e.preventDefault();
setFocusIndex(i => Math.min(i + 1, items.length - 1));
break;
case 'ArrowUp':
e.preventDefault();
setFocusIndex(i => Math.max(i - 1, 0));
break;
case 'Home':
e.preventDefault();
setFocusIndex(0);
break;
case 'End':
e.preventDefault();
setFocusIndex(items.length - 1);
break;
}
};
return (
<ul role="menu" onKeyDown={handleKeyDown}>
{items.map((item, index) => (
<li
key={item.id}
role="menuitem"
tabIndex={index === focusIndex ? 0 : -1}
>
{item.label}
</li>
))}
</ul>
);
}
Focus Management
function Modal({ isOpen, onClose, children }: ModalProps) {
const modalRef = useRef<HTMLDivElement>(null);
const previousFocus = useRef<HTMLElement | null>(null);
useEffect(() => {
if (isOpen) {
// Save current focus
previousFocus.current = document.activeElement as HTMLElement;
// Focus modal
modalRef.current?.focus();
} else {
// Restore focus
previousFocus.current?.focus();
}
}, [isOpen]);
// Trap focus inside modal
const handleKeyDown = (e: React.KeyboardEvent) => {
if (e.key === 'Tab') {
const focusableElements = modalRef.current?.querySelectorAll(
'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])'
);
// ... trap focus logic
}
if (e.key === 'Escape') {
onClose();
}
};
if (!isOpen) return null;
return (
<div
ref={modalRef}
role="dialog"
aria-modal="true"
tabIndex={-1}
onKeyDown={handleKeyDown}
>
{children}
</div>
);
}
Testing
Component Testing
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { Button } from './Button';
describe('Button', () => {
it('renders children', () => {
render(<Button>Click me</Button>);
expect(screen.getByRole('button', { name: /click me/i })).toBeInTheDocument();
});
it('calls onClick when clicked', async () => {
const handleClick = jest.fn();
render(<Button onClick={handleClick}>Click</Button>);
await userEvent.click(screen.getByRole('button'));
expect(handleClick).toHaveBeenCalledTimes(1);
});
it('does not call onClick when disabled', async () => {
const handleClick = jest.fn();
render(<Button onClick={handleClick} disabled>Click</Button>);
await userEvent.click(screen.getByRole('button'));
expect(handleClick).not.toHaveBeenCalled();
});
it('shows loading spinner when loading', () => {
render(<Button loading>Submit</Button>);
expect(screen.getByRole('button')).toHaveAttribute('aria-busy', 'true');
});
});
Hook Testing
import { renderHook, act } from '@testing-library/react';
import { useToggle } from './useToggle';
describe('useToggle', () => {
it('initializes with default value', () => {
const { result } = renderHook(() => useToggle(false));
expect(result.current[0]).toBe(false);
});
it('toggles value', () => {
const { result } = renderHook(() => useToggle(false));
act(() => {
result.current[1](); // toggle
});
expect(result.current[0]).toBe(true);
});
});
Performance Optimization
Memoization
// Memoize component to prevent unnecessary re-renders
const ExpensiveList = memo(({ items }: { items: Item[] }) => {
return (
<ul>
{items.map(item => (
<li key={item.id}>{item.name}</li>
))}
</ul>
);
});
// Custom comparison function
const UserCard = memo(
({ user }: { user: User }) => <div>{user.name}</div>,
(prevProps, nextProps) => prevProps.user.id === nextProps.user.id
);
Code Splitting
import { lazy, Suspense } from 'react';
// Lazy load components
const Dashboard = lazy(() => import('./Dashboard'));
const Settings = lazy(() => import('./Settings'));
function App() {
return (
<Suspense fallback={<LoadingSpinner />}>
<Routes>
<Route path="/dashboard" element={<Dashboard />} />
<Route path="/settings" element={<Settings />} />
</Routes>
</Suspense>
);
}
Virtualization
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,
});
return (
<div ref={parentRef} style={{ height: '400px', overflow: 'auto' }}>
<div style={{ height: `${virtualizer.getTotalSize()}px`, position: 'relative' }}>
{virtualizer.getVirtualItems().map(virtualItem => (
<div
key={virtualItem.key}
style={{
position: 'absolute',
top: 0,
left: 0,
width: '100%',
height: `${virtualItem.size}px`,
transform: `translateY(${virtualItem.start}px)`,
}}
>
{items[virtualItem.index].name}
</div>
))}
</div>
</div>
);
}
Common Mistakes
| Mistake | Problem | Solution |
|---------|---------|----------|
| State mutation | state.push(item) | [...state, item] |
| Missing key | List renders slow | Use unique key prop |
| Object in deps | Infinite loop | useMemo for objects |
| Missing cleanup | Memory leak | Return cleanup function |
| Stale closure | Wrong values | Add to dependency array |
| Over-rendering | Slow UI | React.memo, useMemo |