React Best Practices: Architecture, Performance, and Quality in 2025

React is deliberately unopinionated — it's a library, not a framework. That flexibility is real, and so is the cost: every team ends up building their own implicit framework through accumulated conventions. The difference between a React codebase that's a pleasure to work in and one that's a maze of workarounds comes down to a handful of decisions made (or not made) early on.

1. Component Design: Small, Focused, and Composable

The most valuable mental model in React is thinking in composition rather than inheritance or hierarchy. A component should do one thing and do it well. The signal that a component needs to be split: you need to scroll to understand what it renders.

// ✗ Monolithic component — renders, fetches, and manages all state in one place function UserDashboard() { const [users, setUsers] = useState([]); const [loading, setLoading] = useState(false); const [selected, setSelected] = useState(null); // 150 lines of JSX... } // ✓ Composed from focused pieces — each testable independently function UserDashboard() { return ( <DashboardLayout> <UserListContainer /> {/* fetches + manages list state */} <UserDetailPanel /> {/* reads selected user from store */} </DashboardLayout> ); }

The children prop and render props are underused composition tools. Passing UI as props rather than hardcoding it inside a component decouples layout from behavior and makes reuse trivial without adding abstraction layers.

// Compound pattern — state stays in the parent, rendering is delegated <DataTable data={orders}> <DataTable.Header /> <DataTable.Body renderRow={order => <OrderRow key={order.id} order={order} />} /> <DataTable.Pagination /> </DataTable>

2. Hooks: The Five Mistakes Everyone Makes

useEffect for everything

useEffect is the most misused hook. The React team's own guidance: most things you put in useEffect don't belong there. Data fetching belongs in a data-fetching library (React Query, SWR, RTK Query). Derived state belongs in a calculation during render, not a useEffect that syncs two state variables. Event responses belong in event handlers, not effects.

// ✗ useEffect to compute derived state const [items, setItems] = useState([]); const [total, setTotal] = useState(0); useEffect(() => { setTotal(items.reduce((s, i) => s + i.price, 0)); }, [items]); // ✓ Derive during render — no effect, no extra re-render const [items, setItems] = useState([]); const total = items.reduce((s, i) => s + i.price, 0); // recalculated on render // ✗ useEffect to fetch on mount — no caching, no deduplication, no loading state management useEffect(() => { fetch('/api/users').then(r => r.json()).then(setUsers); }, []); // ✓ React Query handles all of this — caching, background refetch, error boundaries const { data: users, isLoading } = useQuery({ queryKey: ['users'], queryFn: fetchUsers });

Stale closures in effects and callbacks

// ✗ Stale closure — count is captured at 0, never updates useEffect(() => { const id = setInterval(() => console.log(count), 1000); return () => clearInterval(id); }, []); // missing count dependency // ✓ Add the dependency, or use a ref for values you want to read without re-subscribing const countRef = useRef(count); useEffect(() => { countRef.current = count; }); useEffect(() => { const id = setInterval(() => console.log(countRef.current), 1000); return () => clearInterval(id); }, []);

Missing cleanup in effects

// ✗ Sets state after component unmounts — React 18 warning, potential memory leak useEffect(() => { fetchUser(id).then(user => setUser(user)); }, [id]); // ✓ AbortController cancels the fetch when component unmounts or id changes useEffect(() => { const controller = new AbortController(); fetchUser(id, { signal: controller.signal }).then(user => setUser(user)); return () => controller.abort(); }, [id]);

Overusing useState instead of useReducer

When you have three or more state variables that change together, or transitions that involve complex conditions, useReducer is cleaner than multiple useState calls. It centralizes logic, makes state transitions explicit, and is trivial to test (pure function — no hooks required).

Not extracting custom hooks

Any stateful logic that appears in more than one component belongs in a custom hook. Custom hooks are the primary reuse mechanism in React — they extract the why (the logic) from the what (the rendering). A component that is 60% hook calls and 40% JSX should have its hook calls extracted.

// Custom hook encapsulates the logic — component focuses on rendering function useUserSearch(initialQuery = '') { const [query, setQuery] = useState(initialQuery); const debouncedQuery = useDebounce(query, 300); const { data, isLoading } = useQuery({ queryKey: ['users', 'search', debouncedQuery], queryFn: () => searchUsers(debouncedQuery), enabled: debouncedQuery.length > 1 }); return { query, setQuery, results: data ?? [], isLoading }; } function UserSearchPage() { const { query, setQuery, results, isLoading } = useUserSearch(); return ( <> <SearchInput value={query} onChange={setQuery} /> <UserList users={results} loading={isLoading} /> </> ); }

3. State Management: The Decision Tree

1. Can the state live in a single component?
Use useState or useReducer. Don't pull it into global state prematurely — it creates invisible coupling and makes the component harder to reuse.
2. Is it server state (remote data)?
Use React Query or SWR. Server state has different concerns: caching, background sync, stale time, error retry. These libraries handle all of it. Putting API data in Redux or Zustand is almost always the wrong call.
3. Is it shared across a subtree of components?
Use Context for low-frequency updates (theme, locale, auth user). Context re-renders all consumers on every change — avoid it for high-frequency state like form input values or scroll position.
4. Is it truly global client state?
Use Zustand (simple, low boilerplate) or Redux Toolkit (powerful, auditable). Real global client state is rare: shopping cart, user preferences, notification queue. Most things teams put in Redux belong in React Query or local state.
Most state belongs in levels 1–2. Reaching for level 4 (Redux/Zustand) for server data is a very common mistake.

4. Performance: When memo Actually Helps (and When It Hurts)

React.memo, useMemo, and useCallback are optimization tools, not default patterns. They add cost (memory for the cached value, CPU for the comparison). They're only beneficial when the comparison is cheaper than the work being avoided — which is not always true.

// ✗ Memoizing a cheap computation — the memo overhead exceeds the benefit const label = useMemo(() => `${firstName} ${lastName}`, [firstName, lastName]); // ✓ Just compute it — string concatenation is not worth memoizing const label = `${firstName} ${lastName}`; // ✓ useMemo for genuinely expensive computations const sortedAndFiltered = useMemo(() => items.filter(predicate).sort(comparator), [items, predicate, comparator] ); // ✓ useCallback when a function is passed to a memoized child const handleSelect = useCallback((id: string) => { setSelected(id); }, []); // only useful if UserRow is wrapped in React.memo

The memo contract: React.memo on a child component only prevents re-renders if all its props are stable across parent renders. If the parent creates a new function or object inline, the prop is a new reference every render and memo does nothing. useCallback and useMemo are only useful when their result is passed to a React.memo-wrapped component or is itself used as a dependency.

5. Code Splitting and Lazy Loading

// Lazy load route-level components — initial bundle contains only the shell const UserPage = lazy(() => import('./features/users/UserPage')); const OrdersPage = lazy(() => import('./features/orders/OrdersPage')); function App() { return ( <Suspense fallback={<PageSkeleton />}> <Routes> <Route path="/users" element={<UserPage />} /> <Route path="/orders" element={<OrdersPage />} /> </Routes> </Suspense> ); } // Lazy load heavy non-route components too — charts, rich text editors, modals const Chart = lazy(() => import('./components/Chart')); {showChart && <Suspense fallback={<ChartSkeleton />}><Chart /></Suspense>}

Route-level splitting should be non-negotiable. A React SPA that doesn't code-split loads the JavaScript for every feature on every page load — users pay the cost of features they never visit. Bundle analysis with vite-bundle-analyzer or webpack-bundle-analyzer makes the cost visible.

6. Project Structure That Scales

src/ ├── features/ # one folder per domain feature │ ├── users/ │ │ ├── components/ # presentational, feature-scoped │ │ ├── hooks/ # custom hooks for this feature │ │ ├── api/ # React Query hooks (useUsers, useCreateUser) │ │ ├── store/ # Zustand slice or Redux slice if needed │ │ ├── types.ts # domain types scoped to this feature │ │ └── index.ts # public API — only export what other features need │ └── orders/ ├── shared/ # truly shared UI — Button, Modal, Input, Table │ └── components/ ├── lib/ # framework setup — queryClient, store, router └── app/ # routes, App component, providers

The index.ts barrel file at each feature boundary acts as an explicit public API. Other features import from features/users, not from features/users/components/UserCard/UserCard.tsx. This encapsulation makes refactoring within a feature safe and makes cross-feature dependencies explicit and reviewable.

7. Forms: Controlled vs. Uncontrolled

Controlled inputs (value={state}) are the React default and give you fine-grained control. But they re-render on every keystroke. For large forms (20+ fields), this is measurable. Uncontrolled inputs with useRef or a form library like React Hook Form (which uses uncontrolled inputs internally) are significantly faster for complex forms.

// React Hook Form — uncontrolled under the hood, minimal re-renders const { register, handleSubmit, formState: { errors } } = useForm<UserForm>({ resolver: zodResolver(userSchema) // Zod schema for validation }); return ( <form onSubmit={handleSubmit(onSubmit)}> <input {...register('email')} /> {errors.email && <span>{errors.email.message}</span>} </form> );

8. Error Boundaries: Failing Gracefully

A runtime error in any component without an Error Boundary crashes the entire React tree. Error Boundaries are class components (or use the react-error-boundary library) that catch render errors and display fallback UI instead of a blank page.

// Wrap every major section — not just the root import { ErrorBoundary } from 'react-error-boundary'; function App() { return ( <ErrorBoundary fallback={<AppCrash />}> <Header /> <ErrorBoundary fallback={<SectionError section="dashboard" />}> <Dashboard /> </ErrorBoundary> <ErrorBoundary fallback={<SectionError section="sidebar" />}> <Sidebar /> </ErrorBoundary> </ErrorBoundary> ); }

9. Testing with React Testing Library

React Testing Library's guiding principle: test behavior, not implementation. Query by what the user sees (text, role, label), not by class names or component internals. Tests that break when you refactor without changing behavior are not tests — they're maintenance liabilities.

// ✗ Tests implementation details — breaks on any refactor expect(wrapper.find('.user-card__title').text()).toBe('Alice'); // ✓ Tests what the user sees — survives any refactor that keeps the behavior render(<UserCard user={alice} />); expect(screen.getByRole('heading', { name: 'Alice' })).toBeInTheDocument(); // ✓ Testing an interaction await userEvent.click(screen.getByRole('button', { name: /delete/i })); expect(screen.queryByText('Alice')).not.toBeInTheDocument();

Query priority in RTL: getByRole > getByLabelText > getByPlaceholderText > getByText. Roles map to what screen readers announce — tests that use them double as accessibility checks.

10. React 18 Concurrent Features

React 18's concurrent mode doesn't change how you write components — it changes how React decides when to render them. Two hooks expose this power directly to application code: useTransition and useDeferredValue. Both solve the same problem: keeping the UI responsive during expensive updates.

// useTransition — mark a state update as non-urgent // UI stays responsive; the update is deferred until React has idle time const [isPending, startTransition] = useTransition(); function handleSearch(value: string) { setInputValue(value); // urgent: update input immediately startTransition(() => setSearchQuery(value)); // non-urgent: filter 10,000 items } return ( <> <input value={inputValue} onChange={e => handleSearch(e.target.value)} /> {isPending && <Spinner />} <ItemList query={searchQuery} /> {/* renders with deferred value */} </> ); // useDeferredValue — defer the expensive part without owning the state update function SearchResults({ query }: { query: string }) { const deferredQuery = useDeferredValue(query); const isStale = query !== deferredQuery; return ( <div style={{ opacity: isStale ? 0.6 : 1 }}> {/* visual stale indicator */} <ExpensiveList query={deferredQuery} /> </div> ); }

useTransition vs useDeferredValue: Use useTransition when you own the state update (you control when setState fires). Use useDeferredValue when you receive a value as a prop or from an external source you don't control. Both defer the expensive downstream render; they differ in where the deferral is applied.

11. TypeScript Patterns That Make Components Safer

React and TypeScript have strong synergy, but most teams only use TypeScript for basic prop typing. A few patterns go much further in making invalid states unrepresentable at compile time.

// Discriminated unions for component variants — impossible to pass wrong combination type ButtonProps = | { variant: 'primary'; onClick: () => void; href?: never } | { variant: 'link'; href: string; onClick?: never }; // ✗ Type error — link variant requires href, not onClick <Button variant="link" onClick={handleClick} /> // Polymorphic component — preserves the correct HTML element's prop types type AsProps<T extends ElementType> = { as?: T } & ComponentPropsWithoutRef<T>; function Text<T extends ElementType = 'p'>({ as, ...props }: AsProps<T>) { const Tag = as ?? 'p'; return <Tag {...props} />; } <Text as="h1" id="title">Heading</Text> // id is valid; href would be a type error // Generic data components — no any, full type inference through function DataTable<T>({ data, columns }: { data: T[]; columns: { key: keyof T; label: string }[]; }) { ... } <DataTable data={users} // T inferred as User columns={[{ key: 'email', label: 'Email' }]} // key must be keyof User />

12. Server Components and the Next.js App Router

React Server Components (RSC) — the foundation of Next.js 13+ App Router — fundamentally change the rendering model. Server Components run only on the server: they can access databases and secrets directly, never ship JavaScript to the client, and are ideal for data fetching. Client Components (marked 'use client') run in the browser and handle interactivity.

// Server Component — runs on server, zero JS sent to client, direct DB access // app/users/page.tsx (no 'use client' directive = Server Component by default) export default async function UsersPage() { const users = await db.users.findMany(); // direct DB call, no API needed return <UserList users={users} />; } // Client Component — needed for: useState, useEffect, event handlers, browser APIs 'use client'; export function UserList({ users }: { users: User[] }) { const [selected, setSelected] = useState<string | null>(null); return ( <ul> {users.map(user => ( <li key={user.id} onClick={() => setSelected(user.id)}>{user.name}</li> ))} </ul> ); }

The decision rule: Default to Server Components. Add 'use client' only when you need: useState, useEffect, event listeners, browser-only APIs (window, localStorage), or third-party libraries that aren't RSC-compatible. Push 'use client' as far down the component tree as possible — a leaf interactive button doesn't require its entire parent tree to be a Client Component.

13. Common Mistakes That Compound Over Time

Key Takeaways

Back to all articles