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

FeatureuseStateuseReducerZustandJotaiRedux Toolkit
Learning CurveEasyMediumEasyEasyMedium-High
BoilerplateNoneLowMinimalMinimalModerate
Bundle Size0 KB0 KB~1 KB~2 KB~11 KB
DevToolsReact DevToolsReact DevToolsBuilt-inDevTools extensionRedux DevTools
PersistenceManualManualMiddlewareatomWithStorageredux-persist
Best ForComponent stateComplex state logicApp-wide stateAtomic stateLarge 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-label to icon-only buttons
  • Ensure all interactive elements are keyboard accessible
  • Use role="alert" for dynamic error messages
  • Maintain a visible focus indicator (never outline: none without 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

  1. Install the React DevTools browser extension
  2. Open the Profiler tab in DevTools
  3. Click the record button, interact with your app, then stop
  4. Analyze the flame graph to find components that re-render unnecessarily
  5. Look for components with long render times (highlighted in yellow/red)
  6. Use Highlight updates to see which components re-render in real time

Quick Reference Cheat Sheet

PatternUse 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 ComponentsData fetching, static content, SEO pages
Client ComponentsInteractivity, browser APIs, event handlers
TanStack QueryServer state, caching, background refetching
ZustandGlobal client state, auth, UI preferences