Modern React development requires understanding far more than just components and props. You need to master state management, data fetching, memoization, and the new paradigm of React Server Components (RSC). This guide covers the architectural patterns used by senior React developers.
Step 1 — State Management: useState vs useReducer
useState is perfect for simple, independent values. However, when state logic becomes complex or multiple sub-values depend on each other, useReducer is the better choice.
import { useReducer } from 'react';
type State = {
count: number;
error: string | null;
};
type Action =
| { type: 'increment'; payload: number }
| { type: 'decrement'; payload: number }
| { type: 'reset' };
function reducer(state: State, action: Action): State {
switch (action.type) {
case 'increment':
if (state.count >= 10) return { ...state, error: 'Max reached' };
return { count: state.count + action.payload, error: null };
case 'decrement':
return { count: state.count - action.payload, error: null };
case 'reset':
return { count: 0, error: null };
default:
return state;
}
}
export function Counter() {
const [state, dispatch] = useReducer(reducer, { count: 0, error: null });
return (
<div>
<p>Count: {state.count}</p>
{state.error && <p className="error">{state.error}</p>}
<button onClick={() => dispatch({ type: 'increment', payload: 1 })}>+</button>
<button onClick={() => dispatch({ type: 'decrement', payload: 1 })}>-</button>
</div>
);
}Step 2 — useEffect Deep Dive & Cleanup
useEffect lets you synchronize a component with an external system (network, DOM, subscriptions). The dependency array controls when the effect runs.
import { useState, useEffect } from 'react';
export function UserProfile({ userId }: { userId: string }) {
const [data, setData] = useState<any>(null);
useEffect(() => {
// Create an AbortController to cancel fetches if component unmounts
// or if userId changes before the fetch completes
const controller = new AbortController();
async function fetchUser() {
try {
const res = await fetch(`/api/users/${userId}`, {
signal: controller.signal
});
const json = await res.json();
setData(json);
} catch (err: any) {
if (err.name !== 'AbortError') {
console.error('Fetch failed', err);
}
}
}
fetchUser();
// Cleanup function: runs before the next effect or on unmount
return () => {
controller.abort();
};
}, [userId]); // Only re-run if userId changes
return <div>{data ? data.name : 'Loading...'}</div>;
}Step 3 — Performance Optimization: Memoization
React re-renders a component when its state or props change, AND it recursively re-renders all children. useMemo, useCallback, and React.memo prevent unnecessary work.
import { useState, useMemo, useCallback, memo } from 'react';
// 1. React.memo: Skip re-rendering if props haven't changed (shallow comparison)
const ExpensiveList = memo(function ExpensiveList({ items, onItemClick }: any) {
console.log('List rendered');
return (
<ul>
{items.map(item => (
<li key={item.id} onClick={() => onItemClick(item.id)}>{item.name}</li>
))}
</ul>
);
});
export function Parent() {
const [count, setCount] = useState(0);
const [search, setSearch] = useState('');
// 2. useMemo: Cache expensive calculations or object references
const expensiveItems = useMemo(() => {
console.log('Calculating items...');
return generateThousandItems().filter(i => i.name.includes(search));
}, [search]); // Only re-run when 'search' changes
// 3. useCallback: Cache function references
// If we didn't use useCallback, a new function reference would be created on
// every render, breaking the React.memo optimization on ExpensiveList.
const handleItemClick = useCallback((id: number) => {
console.log('Clicked', id);
}, []); // Empty deps: function never changes
return (
<div>
{/* Changing count won't re-render ExpensiveList! */}
<button onClick={() => setCount(c => c + 1)}>Count: {count}</button>
<ExpensiveList items={expensiveItems} onItemClick={handleItemClick} />
</div>
);
}Step 4 — Global State Management: Zustand
Context API is fine for low-frequency updates (like theme), but it causes re-renders of all consumers. Zustand is a fast, scalable, barebones state-management solution.
import { create } from 'zustand';
import { persist } from 'zustand/middleware';
type AppState = {
bears: number;
increase: (by: number) => void;
removeAll: () => void;
};
// Create the store (with localStorage persistence middleware)
export const useStore = create<AppState>()(
persist(
(set) => ({
bears: 0,
increase: (by) => set((state) => ({ bears: state.bears + by })),
removeAll: () => set({ bears: 0 }),
}),
{ name: 'bear-storage' }
)
);
// Usage in component (auto-subscribes ONLY to selected state)
function BearCounter() {
const bears = useStore((state) => state.bears);
return <h1>{bears} bears around here</h1>;
}
function Controls() {
const increase = useStore((state) => state.increase);
return <button onClick={() => increase(1)}>Add Bear</button>;
}Step 5 — Data Fetching with TanStack Query
useEffect is terrible for data fetching (no caching, no retries, race conditions). TanStack Query (formerly React Query) solves all server-state problems.
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
function Todos() {
const queryClient = useQueryClient();
// Fetch data (cached automatically)
const { data: todos, isLoading, error } = useQuery({
queryKey: ['todos'],
queryFn: () => fetch('/todos').then(res => res.json()),
});
// Mutate data (POST/PUT/DELETE)
const mutation = useMutation({
mutationFn: (newTodo: string) => {
return fetch('/todos', {
method: 'POST',
body: JSON.stringify({ title: newTodo })
});
},
// Optimistic updates or invalidate cache on success
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['todos'] });
},
});
if (isLoading) return <span>Loading...</span>;
if (error) return <span>Error: {error.message}</span>;
return (
<div>
<ul>{todos.map(t => <li key={t.id}>{t.title}</li>)}</ul>
<button onClick={() => mutation.mutate('Do Laundry')}>
Add Todo
</button>
</div>
);
}Step 6 — React Server Components (RSC)
RSC (introduced in React 18, popularized by Next.js) allows components to run exclusively on the server, resulting in zero client-side JavaScript for those components.
Server Components vs Client Components
- Server Components (Default): Can access DB directly, keep large dependencies on server, zero bundle size impact. CANNOT use state, effects, or DOM events (onClick).
- Client Components ('use client'): Standard React components. Required for interactivity (useState, useEffect, onClick).
- Rule: Pass Server Components as children to Client Components, not the other way around.
// app/page.tsx (Server Component)
import db from './db';
import { InteractiveButton } from './InteractiveButton';
// Can be async!
export default async function Page() {
// Fetch directly from DB — no API route needed!
const data = await db.query('SELECT * FROM posts');
return (
<div>
<h1>Server rendered posts</h1>
<ul>
{data.map(post => <li key={post.id}>{post.title}</li>)}
</ul>
{/* Pass data to a Client Component for interactivity */}
<InteractiveButton id={data[0].id} />
</div>
);
}
// app/InteractiveButton.tsx
'use client'; // Opt-in to client-side JS
import { useState } from 'react';
export function InteractiveButton({ id }: { id: string }) {
const [likes, setLikes] = useState(0);
return <button onClick={() => setLikes(l => l + 1)}>Like {likes}</button>;
}