React in 2025: What Has Changed
React has undergone massive evolution. With React 19 now stable, the framework has embraced Server Components, Actions, and new hooks that fundamentally change how we build applications. This guide covers everything you need to write modern, performant React code in 2025 — from the latest features to proven patterns, state management comparisons, and performance optimization techniques you can apply to any project today.
React 19 Features You Must Know
Server Components
React Server Components (RSC) let you render components entirely on the server. They can access databases, file systems, and internal APIs directly without exposing them to the client. The client only receives the rendered HTML and a minimal amount of JavaScript.
// app/posts/page.tsx — This is a Server Component by default
import { db } from '@/lib/database';
export default async function PostsPage() {
// Direct database query — this code never reaches the client
const posts = await db.query('SELECT * FROM posts ORDER BY created_at DESC');
return (
<div>
<h1>Blog Posts</h1>
{posts.map(post => (
<article key={post.id}>
<h2>{post.title}</h2>
<p>{post.excerpt}</p>
</article>
))}
</div>
);
}
// components/LikeButton.tsx — Client Component (needs interactivity)
'use client';
import { useState } from 'react';
export function LikeButton({ postId }) {
const [likes, setLikes] = useState(0);
return (
<button onClick={() => setLikes(prev => prev + 1)}>
❤️ {likes}
</button>
);
}
Actions and useActionState
Actions simplify form handling and mutations. They work with both server and client components, replacing the old pattern of manually managing loading states and error handling.
// Server Action for form submission
'use server';
export async function createPost(formData) {
const title = formData.get('title');
const content = formData.get('content');
// Validate
if (!title || title.length < 3) {
return { error: 'Title must be at least 3 characters' };
}
// Save to database
await db.posts.create({ title, content });
revalidatePath('/posts');
return { success: true };
}
// Client component using the action
'use client';
import { useActionState } from 'react';
import { createPost } from './actions';
export function CreatePostForm() {
const [state, formAction, isPending] = useActionState(createPost, null);
return (
<form action={formAction}>
<input name="title" placeholder="Post title" required />
<textarea name="content" placeholder="Write your post..." />
{state?.error && <p className="error">{state.error}</p>}
<button type="submit" disabled={isPending}>
{isPending ? 'Creating...' : 'Create Post'}
</button>
</form>
);
}
The use() Hook
The use() hook lets you read resources like Promises and Contexts. Unlike other hooks, use() can be called conditionally inside loops and if statements.
import { use, Suspense } from 'react';
function UserProfile({ userPromise }) {
// use() unwraps the promise — React suspends until resolved
const user = use(userPromise);
return (
<div>
<h2>{user.name}</h2>
<p>{user.email}</p>
</div>
);
}
// Usage with Suspense
export default function Page() {
const userPromise = fetchUser(1); // starts fetching immediately
return (
<Suspense fallback={<p>Loading user...</p>}>
<UserProfile userPromise={userPromise} />
</Suspense>
);
}
Custom Hooks Patterns
Custom hooks let you extract reusable logic from components. Here are production-ready implementations of the most useful custom hooks.
useLocalStorage
import { useState, useEffect } from 'react';
function useLocalStorage(key, initialValue) {
const [storedValue, setStoredValue] = useState(() => {
if (typeof window === 'undefined') return initialValue;
try {
const item = window.localStorage.getItem(key);
return item ? JSON.parse(item) : initialValue;
} catch (error) {
console.warn(`Error reading localStorage key "${key}":`, error);
return initialValue;
}
});
useEffect(() => {
try {
window.localStorage.setItem(key, JSON.stringify(storedValue));
} catch (error) {
console.warn(`Error setting localStorage key "${key}":`, error);
}
}, [key, storedValue]);
return [storedValue, setStoredValue];
}
// Usage
function ThemeToggle() {
const [theme, setTheme] = useLocalStorage('theme', 'light');
return (
<button onClick={() => setTheme(theme === 'light' ? 'dark' : 'light')}>
Current: {theme}
</button>
);
}
useFetch
import { useState, useEffect, useRef } from 'react';
function useFetch(url, options = {}) {
const [data, setData] = useState(null);
const [error, setError] = useState(null);
const [loading, setLoading] = useState(true);
const abortControllerRef = useRef(null);
useEffect(() => {
// Cancel previous request
abortControllerRef.current?.abort();
abortControllerRef.current = new AbortController();
const fetchData = async () => {
setLoading(true);
setError(null);
try {
const response = await fetch(url, {
...options,
signal: abortControllerRef.current.signal,
});
if (!response.ok) throw new Error(`HTTP ${response.status}`);
const json = await response.json();
setData(json);
} catch (err) {
if (err.name !== 'AbortError') {
setError(err.message);
}
} finally {
setLoading(false);
}
};
fetchData();
return () => abortControllerRef.current?.abort();
}, [url]);
return { data, error, loading };
}
// Usage
function UserList() {
const { data: users, error, loading } = useFetch('/api/users');
if (loading) return <p>Loading...</p>;
if (error) return <p>Error: {error}</p>;
return users.map(u => <p key={u.id}>{u.name}</p>);
}
useDebounce
import { useState, useEffect } from 'react';
function useDebounce(value, delay = 500) {
const [debouncedValue, setDebouncedValue] = useState(value);
useEffect(() => {
const timer = setTimeout(() => setDebouncedValue(value), delay);
return () => clearTimeout(timer);
}, [value, delay]);
return debouncedValue;
}
// Usage: Search with debounce
function SearchInput() {
const [query, setQuery] = useState('');
const debouncedQuery = useDebounce(query, 300);
const { data } = useFetch(
debouncedQuery ? `/api/search?q=${debouncedQuery}` : null
);
return (
<div>
<input
value={query}
onChange={e => setQuery(e.target.value)}
placeholder="Search..."
/>
{data?.results?.map(r => <p key={r.id}>{r.title}</p>)}
</div>
);
}
useMediaQuery
import { useState, useEffect } from 'react';
function useMediaQuery(query) {
const [matches, setMatches] = useState(false);
useEffect(() => {
const media = window.matchMedia(query);
setMatches(media.matches);
const listener = (event) => setMatches(event.matches);
media.addEventListener('change', listener);
return () => media.removeEventListener('change', listener);
}, [query]);
return matches;
}
// Usage
function ResponsiveLayout() {
const isMobile = useMediaQuery('(max-width: 768px)');
const isDarkMode = useMediaQuery('(prefers-color-scheme: dark)');
return (
<div>
{isMobile ? <MobileNav /> : <DesktopNav />}
{isDarkMode && <p>Dark mode is active</p>}
</div>
);
}
State Management Comparison
| Feature | useState | useReducer | Zustand | Jotai | Redux Toolkit |
|---|---|---|---|---|---|
| Learning Curve | Easy | Medium | Easy | Easy | Medium-High |
| Boilerplate | None | Low | Minimal | Minimal | Moderate |
| Bundle Size | 0 KB | 0 KB | ~1 KB | ~2 KB | ~11 KB |
| DevTools | React DevTools | React DevTools | Built-in | DevTools extension | Redux DevTools |
| Persistence | Manual | Manual | Middleware | atomWithStorage | redux-persist |
| Best For | Component state | Complex state logic | App-wide state | Atomic state | Large enterprise apps |
Zustand Example (Recommended for Most Apps)
import { create } from 'zustand';
import { persist, devtools } from 'zustand/middleware';
const useAuthStore = create(
devtools(
persist(
(set, get) => ({
user: null,
token: null,
isAuthenticated: false,
login: async (email, password) => {
const res = await fetch('/api/auth/login', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email, password }),
});
const data = await res.json();
set({
user: data.user,
token: data.token,
isAuthenticated: true,
});
},
logout: () => {
set({ user: null, token: null, isAuthenticated: false });
},
getAuthHeader: () => ({
Authorization: `Bearer ${get().token}`,
}),
}),
{ name: 'auth-storage' }
)
)
);
// Usage in any component — no Provider needed
function Navbar() {
const { user, isAuthenticated, logout } = useAuthStore();
if (!isAuthenticated) return <LoginButton />;
return (
<div>
<span>Welcome, {user.name}</span>
<button onClick={logout}>Logout</button>
</div>
);
}
React.memo, useMemo, and useCallback — When to Actually Use Them
These are optimization tools. The golden rule: do not optimize prematurely. Use them only when you have a measured performance problem.
React.memo — Prevent Unnecessary Re-renders
// Use when: a component receives the same props frequently but its parent re-renders often
const ExpensiveList = React.memo(function ExpensiveList({ items, onSelect }) {
console.log('ExpensiveList rendered');
return (
<ul>
{items.map(item => (
<li key={item.id} onClick={() => onSelect(item.id)}>
{item.name}
</li>
))}
</ul>
);
});
// DO NOT use when:
// - Props change on every render anyway
// - The component is lightweight and cheap to render
// - You are wrapping everything "just in case"
useMemo — Cache Expensive Calculations
function Dashboard({ transactions }) {
// USE useMemo: filtering and sorting thousands of items is expensive
const summary = useMemo(() => {
return {
total: transactions.reduce((sum, t) => sum + t.amount, 0),
sorted: [...transactions].sort((a, b) => b.date - a.date),
categories: groupByCategory(transactions),
};
}, [transactions]);
// DON'T use useMemo for cheap operations
// const fullName = useMemo(() => `${first} ${last}`, [first, last]); // Overkill!
const fullName = `${first} ${last}`; // Just compute it directly
return <SummaryView data={summary} />;
}
useCallback — Stabilize Function References
function ParentComponent({ items }) {
// USE useCallback when passing callbacks to memoized children
const handleSelect = useCallback((id) => {
console.log('Selected:', id);
}, []);
// This function is stable across renders, so ExpensiveList won't re-render
return <ExpensiveList items={items} onSelect={handleSelect} />;
}
Code Splitting with React.lazy and Suspense
import { lazy, Suspense } from 'react';
// Lazy-loaded components
const Dashboard = lazy(() => import('./pages/Dashboard'));
const Settings = lazy(() => import('./pages/Settings'));
const Analytics = lazy(() => import('./pages/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>
);
}
// Preload a component when the user hovers over a link
function NavLink({ to, children }) {
const handleMouseEnter = () => {
if (to === '/analytics') {
import('./pages/Analytics'); // Preload on hover
}
};
return (
<Link to={to} onMouseEnter={handleMouseEnter}>
{children}
</Link>
);
}
Error Boundaries
import { Component } from 'react';
class ErrorBoundary extends Component {
constructor(props) {
super(props);
this.state = { hasError: false, error: null };
}
static getDerivedStateFromError(error) {
return { hasError: true, error };
}
componentDidCatch(error, errorInfo) {
// Log to an error reporting service
console.error('ErrorBoundary caught:', error, errorInfo);
// logErrorToService(error, errorInfo);
}
render() {
if (this.state.hasError) {
return (
this.props.fallback || (
<div role="alert">
<h2>Something went wrong</h2>
<pre>{this.state.error?.message}</pre>
<button onClick={() => this.setState({ hasError: false })}>
Try Again
</button>
</div>
)
);
}
return this.props.children;
}
}
// Usage
<ErrorBoundary fallback={<p>Widget failed to load</p>}>
<UnstableWidget />
</ErrorBoundary>
TanStack Query for Server State
import { useQuery, useMutation, useQueryClient, QueryClient, QueryClientProvider } from '@tanstack/react-query';
const queryClient = new QueryClient({
defaultOptions: {
queries: {
staleTime: 5 * 60 * 1000, // 5 minutes
gcTime: 10 * 60 * 1000, // 10 minutes (was cacheTime)
retry: 2,
refetchOnWindowFocus: false,
},
},
});
function App() {
return (
<QueryClientProvider client={queryClient}>
<PostsList />
</QueryClientProvider>
);
}
function PostsList() {
const { data: posts, isLoading, error } = useQuery({
queryKey: ['posts'],
queryFn: () => fetch('/api/posts').then(r => r.json()),
});
const queryClient = useQueryClient();
const deleteMutation = useMutation({
mutationFn: (id) => fetch(`/api/posts/${id}`, { method: 'DELETE' }),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['posts'] });
},
});
if (isLoading) return <Skeleton count={5} />;
if (error) return <p>Error: {error.message}</p>;
return posts.map(post => (
<div key={post.id}>
<h3>{post.title}</h3>
<button onClick={() => deleteMutation.mutate(post.id)}>Delete</button>
</div>
));
}
Form Handling with React Hook Form and Zod
# Install dependencies
npm install react-hook-form @hookform/resolvers zod
import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { z } from 'zod';
const signupSchema = z.object({
name: z.string().min(2, 'Name must be at least 2 characters'),
email: z.string().email('Invalid email address'),
password: z
.string()
.min(8, 'Password must be at least 8 characters')
.regex(/[A-Z]/, 'Must contain an uppercase letter')
.regex(/[0-9]/, 'Must contain a number'),
confirmPassword: z.string(),
terms: z.boolean().refine(val => val, 'You must accept the terms'),
}).refine(data => data.password === data.confirmPassword, {
message: 'Passwords do not match',
path: ['confirmPassword'],
});
function SignupForm() {
const {
register,
handleSubmit,
formState: { errors, isSubmitting },
} = useForm({
resolver: zodResolver(signupSchema),
});
const onSubmit = async (data) => {
await fetch('/api/signup', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(data),
});
};
return (
<form onSubmit={handleSubmit(onSubmit)}>
<div>
<input {...register('name')} placeholder="Full Name" />
{errors.name && <span className="error">{errors.name.message}</span>}
</div>
<div>
<input {...register('email')} type="email" placeholder="Email" />
{errors.email && <span className="error">{errors.email.message}</span>}
</div>
<div>
<input {...register('password')} type="password" placeholder="Password" />
{errors.password && <span className="error">{errors.password.message}</span>}
</div>
<div>
<input {...register('confirmPassword')} type="password" placeholder="Confirm" />
{errors.confirmPassword && <span className="error">{errors.confirmPassword.message}</span>}
</div>
<label>
<input {...register('terms')} type="checkbox" /> Accept terms
</label>
{errors.terms && <span className="error">{errors.terms.message}</span>}
<button type="submit" disabled={isSubmitting}>
{isSubmitting ? 'Signing up...' : 'Sign Up'}
</button>
</form>
);
}
Testing with React Testing Library and Vitest
# Setup
npm install -D vitest @testing-library/react @testing-library/jest-dom @testing-library/user-event jsdom
// vite.config.ts
import { defineConfig } from 'vitest/config';
import react from '@vitejs/plugin-react';
export default defineConfig({
plugins: [react()],
test: {
environment: 'jsdom',
globals: true,
setupFiles: './src/test/setup.ts',
},
});
// Counter.test.tsx
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { describe, it, expect } from 'vitest';
import Counter from './Counter';
describe('Counter', () => {
it('renders with initial count of 0', () => {
render(<Counter />);
expect(screen.getByText('Count: 0')).toBeInTheDocument();
});
it('increments count when button is clicked', async () => {
const user = userEvent.setup();
render(<Counter />);
await user.click(screen.getByRole('button', { name: /increment/i }));
expect(screen.getByText('Count: 1')).toBeInTheDocument();
});
it('displays custom initial value', () => {
render(<Counter initialCount={10} />);
expect(screen.getByText('Count: 10')).toBeInTheDocument();
});
});
Accessibility Best Practices
- Use semantic HTML elements:
<button>,<nav>,<main>,<article> - Add
aria-labelto icon-only buttons - Ensure all interactive elements are keyboard accessible
- Use
role="alert"for dynamic error messages - Maintain a visible focus indicator (never
outline: nonewithout a replacement) - Test with screen readers (VoiceOver, NVDA) and axe DevTools
Folder Structure for Large Apps
src/
├── app/ # Routes (Next.js App Router)
│ ├── (auth)/ # Route groups
│ │ ├── login/
│ │ └── register/
│ ├── dashboard/
│ └── layout.tsx
├── components/
│ ├── ui/ # Reusable UI primitives
│ │ ├── Button.tsx
│ │ ├── Input.tsx
│ │ └── Modal.tsx
│ ├── forms/ # Form-specific components
│ └── layout/ # Layout components
├── hooks/ # Custom hooks
├── lib/ # Utility functions, API clients
├── stores/ # Zustand / state stores
├── types/ # TypeScript types/interfaces
├── styles/ # Global styles
└── test/ # Test setup and utilities
Performance Profiling with React DevTools
- Install the React DevTools browser extension
- Open the Profiler tab in DevTools
- Click the record button, interact with your app, then stop
- Analyze the flame graph to find components that re-render unnecessarily
- Look for components with long render times (highlighted in yellow/red)
- Use Highlight updates to see which components re-render in real time
Quick Reference Cheat Sheet
| Pattern | Use When |
|---|---|
React.memo() | Pure component with stable props renders often |
useMemo() | Expensive computation with known dependencies |
useCallback() | Passing callbacks to memoized children |
React.lazy() | Route-level code splitting |
useTransition() | Non-urgent UI updates (search, filtering) |
useDeferredValue() | Deferring expensive renders of received values |
| Server Components | Data fetching, static content, SEO pages |
| Client Components | Interactivity, browser APIs, event handlers |
| TanStack Query | Server state, caching, background refetching |
| Zustand | Global client state, auth, UI preferences |