React Best Practices (2025)
Component Patterns
Functional Components Only
interface UserCardProps { user: User; onSelect?: (user: User) => void; }
function UserCard({ user, onSelect }: UserCardProps) { return ( <article onClick={() => onSelect?.(user)}> <h2>{user.name}</h2> </article> ); }
Props Destructuring
-
Always destructure props in function signature
-
Use default values: { size = 'md' }: Props
-
Spread remaining props: { className, ...rest }
Composition Over Props Drilling
// ❌ Prop drilling <Parent user={user}> <Child user={user}> <GrandChild user={user} /> </Child> </Parent>
// ✅ Composition <UserProvider user={user}> <Parent> <Child> <GrandChild /> </Child> </Parent> </UserProvider>
Page Patterns
Unified Create/Edit Form
Single form component handles both modes via optional entity prop:
interface EntityFormProps { entity?: Entity; onSuccess: (entity: Entity) => void; onCancel: () => void; }
function EntityForm({ entity, onSuccess, onCancel }: EntityFormProps) { const isEdit = Boolean(entity); const [formData, setFormData] = useState(entity ?? initialFormData);
// Conditional mutation based on mode // Edit-only features (delete, publish) render when isEdit }
Pages become thin wrappers:
// CreatePage
<EntityForm onSuccess={(e) => navigate(/entities/${e.id})} onCancel={() => navigate('/entities')} />
// EditPage - fetch data first const { data: entity } = useEntity(id); <EntityForm entity={entity} onSuccess={...} onCancel={...} />
Reusable UI Components
Extract repeated patterns: Pagination , Snackbar , DeleteConfirmModal , StatusBadge
Hooks Best Practices
useState
-
Use functional updates for derived state: setCount(c => c + 1)
-
Prefer multiple states over one object
-
Initialize expensive state with function: useState(() => computeExpensive())
useEffect
-
One effect per concern
-
Always include cleanup when needed
-
Avoid objects in dependency arrays (use primitives)
// ✅ Good: Minimal dependencies useEffect(() => { const handler = () => setWidth(window.innerWidth); window.addEventListener("resize", handler); return () => window.removeEventListener("resize", handler); }, []); // Empty = mount only
useMemo / useCallback
-
Only for expensive computations
-
Only when passing to memoized children
-
Don't over-optimize prematurely
// Memoize expensive filter const filtered = useMemo( () => items.filter((i) => i.name.includes(search)), [items, search], );
// Memoize callback for React.memo child const handleClick = useCallback((id: string) => setSelected(id), []);
Custom Hooks
-
Extract reusable logic
-
Name with use prefix
-
Return tuple or object consistently
function useLocalStorage<T>(key: string, initial: T) { const [value, setValue] = useState<T>(() => { const stored = localStorage.getItem(key); return stored ? JSON.parse(stored) : initial; });
useEffect(() => { localStorage.setItem(key, JSON.stringify(value)); }, [key, value]);
return [value, setValue] as const; }
State Management
Hierarchy (prefer top to bottom)
-
Local state - Component-specific
-
Lifted state - Shared between siblings
-
Context - Cross-cutting (theme, auth)
-
External store - Complex global state (Zustand, Jotai)
When to Use Context
-
Theme/appearance
-
User authentication
-
Locale/i18n
-
Feature flags
Zustand Pattern (Recommended)
const useStore = create<State>((set) => ({ items: [], addItem: (item) => set((s) => ({ items: [...s.items, item] })), removeItem: (id) => set((s) => ({ items: s.items.filter((i) => i.id !== id), })), }));
TypeScript Patterns
Props Types
interface Props { required: string; optional?: number; children: React.ReactNode; onClick: (event: React.MouseEvent) => void; as?: React.ElementType; }
Generic Components
interface ListProps<T> { items: T[]; renderItem: (item: T) => React.ReactNode; keyExtractor: (item: T) => string; }
function List<T>({ items, renderItem, keyExtractor }: ListProps<T>) { return ( <ul> {items.map((item) => ( <li key={keyExtractor(item)}>{renderItem(item)}</li> ))} </ul> ); }
Performance
React.memo
-
Wrap components receiving same props repeatedly
-
Combine with useCallback for function props
-
Don't use everywhere (adds overhead)
Lazy Loading
const HeavyComponent = lazy(() => import("./HeavyComponent"));
function App() { return ( <Suspense fallback={<Spinner />}> <HeavyComponent /> </Suspense> ); }
Keys
-
Use stable, unique IDs (not array index)
-
Changing key forces remount
Testing (Vitest + Testing Library)
import { render, screen, fireEvent } from "@testing-library/react";
describe("Button", () => { it("calls onClick when clicked", () => { const handleClick = vi.fn(); render(<Button onClick={handleClick}>Click</Button>);
fireEvent.click(screen.getByRole("button"));
expect(handleClick).toHaveBeenCalledOnce();
}); });
Common Mistakes
❌ Avoid:
-
Mutating state directly
-
Async operations in render
-
Inline object/array creation in JSX (causes re-renders)
-
Missing keys in lists
-
useEffect without dependencies
-
Over-abstracting too early
✅ Do:
-
Treat state as immutable
-
Use error boundaries
-
Colocate state with usage
-
Memoize expensive computations
-
Use TypeScript strictly