React in 2026: The Complete Guide — Hooks, Server Components, Concurrent Features & Beyond

Table of Contents

Introduction: Why React Still Dominates in 2026

React 19+ — What Changed and Why It Matters

Hooks: The Complete Modern Reference

React Server Components (RSC) Deep Dive

The React Compiler (Formerly React Forget)

Concurrent Features: Transitions, Suspense & Deferred Values

The Actions API: Server Mutations Made Simple

State Management in 2026: Context, Zustand, Jotai & Beyond

Performance Patterns & Optimization

Architecture Patterns for Production Apps

Testing React Applications in 2026

The Ecosystem: Next.js, Remix, TanStack & More

References

1. Introduction: Why React Still Dominates in 2026 {#introduction}

React, released by Meta in 2013, has continuously evolved to meet the demands of modern web development. In 2026, React 19+ stands as the industry standard for building scalable, high-performance user interfaces. With the stabilization of React Server Components (RSC), the Compiler (formerly React Forget), and the Actions API, the ecosystem has matured significantly.

This guide walks you through everything you need to build production-ready React applications in 2026 — from foundational concepts to advanced patterns, with code examples and real-world architecture.

According to the 2025 State of JavaScript survey, React remains the most widely used frontend library at 82% usage among respondents, followed by Vue.js at 46% and Svelte at 23% [1]. Its longevity is not accidental — React's composable model, massive ecosystem, and Meta's continued investment have kept it the default choice for teams of all sizes.

What Makes React in 2026 Different

Server Components are stable — no longer experimental, RSC is the default rendering model in Next.js 13+ and Remix

The React Compiler eliminates manual memoization — useMemo and useCallback are largely unnecessary now

Actions API unifies client and server mutations — forms and data mutations work seamlessly across boundaries

Concurrent rendering is the default — startTransition, useDeferredValue, and Suspense compose naturally

2. React 19+ — What Changed and Why It Matters {#react-19}

React 19 was a landmark release that shipped several long-awaited features. Here is a quick summary of what moved from experimental to stable:

Feature

Status in React 18

Status in React 19+

Server Components

Experimental

Stable

Actions API

Not available

Stable

useOptimistic

Not available

Stable

use() hook

Not available

Stable

React Compiler

Not available

Opt-in stable

Asset loading

Manual

Built-in preloading

Document metadata

Manual (react-helmet)

Built-in ,

Installing React 19+

npm install react@latest react-dom@latest

# TypeScript types
npm install --save-dev @types/react@latest @types/react-dom@latest

Caption: Always install react and react-dom together at the same version. Mismatched versions cause subtle runtime errors that are difficult to debug.

The New use() Hook

The use() hook is one of the most powerful additions in React 19. Unlike other hooks, use() can be called conditionally — including inside loops and if blocks.

import { use, Suspense } from 'react';

// A Promise passed as a prop to a Client Component
function UserProfile({ userPromise }: { userPromise: Promise<User> }) {
// use() unwraps the promise — suspends until resolved
const user = use(userPromise);

return (

{user.name}
{user.email}

);
}

function App() {
const userPromise = fetchUser(1); // Promise created outside

return (
<Suspense fallback={<Skeleton />}>
<UserProfile userPromise={userPromise} />
</Suspense>
);
}

Caption: use() integrates with Suspense automatically. When the promise is pending, React renders the nearest <Suspense> fallback. When it resolves, the component re-renders with the value. Crucially, the Promise must be created outside the component to avoid re-creation on every render.

use() also works with React Context:

import { use } from 'react';
import { ThemeContext } from './ThemeContext';

// Can be called conditionally — unlike useContext
function Button({ isPrimary }: { isPrimary: boolean }) {
if (isPrimary) {
const theme = use(ThemeContext); // Conditional use() — valid!
return Primary ;
}
return Secondary ;
}

3. Hooks: The Complete Modern Reference {#hooks}

Core Hooks — Still the Foundation

useState with TypeScript Best Practices

import { useState } from 'react';

// ✅ Explicit type parameter for non-trivial types
const [user, setUser] = useState<User | null>(null);

// ✅ Lazy initializer for expensive computations
const [data, setData] = useState<ProcessedData>(() => {
return expensiveInitialComputation(); // Runs once, not on every render
});

// ✅ Functional update for state that depends on previous value
setUser(prev => prev ? { ...prev, name: 'Updated' } : null);

Caption: Use the lazy initializer form (useState(() => ...)) whenever the initial state requires computation, array filtering, or parsing. React calls it only on the initial render.

useReducer for Complex State

type CartAction =
| { type: 'ADD_ITEM'; payload: CartItem }
| { type: 'REMOVE_ITEM'; payload: { id: string } }
| { type: 'CLEAR_CART' };

interface CartState {
items: CartItem[];
total: number;
}

function cartReducer(state: CartState, action: CartAction): CartState {
switch (action.type) {
case 'ADD_ITEM':
return {
items: [...state.items, action.payload],
total: state.total + action.payload.price,
};
case 'REMOVE_ITEM':
const item = state.items.find(i => i.id === action.payload.id);
return {
items: state.items.filter(i => i.id !== action.payload.id),
total: state.total - (item?.price ?? 0),
};
case 'CLEAR_CART':
return { items: [], total: 0 };
}
}

function Cart() {
const [cart, dispatch] = useReducer(cartReducer, { items: [], total: 0 });

return (

{cart.items.map(item => (

{item.name}
dispatch({ type: 'REMOVE_ITEM', payload: { id: item.id } })}>
Remove


))}
Total: ${cart.total.toFixed(2)}

);
}

Caption: useReducer shines when multiple pieces of state update together, or when next state depends on previous state in complex ways. The discriminated union type for actions gives you exhaustive type checking across every case.

useEffect in 2026 — What the Compiler Changes

With the React Compiler enabled, the rules for useEffect are simpler: just describe what you want, and the compiler handles dependency arrays.

// Without compiler — manual deps required
useEffect(() => {
fetchUser(userId).then(setUser);
}, [userId]); // Must manually list userId

// With compiler enabled — compiler infers deps automatically
useEffect(() => {
fetchUser(userId).then(setUser);
}); // Compiler adds correct deps at build time

Without the compiler, follow these rules:

// ✅ CORRECT — all reactive values in dep array
const [query, setQuery] = useState('');
const [results, setResults] = useState<Result[]>([]);

useEffect(() => {
if (!query) return;

const controller = new AbortController();

fetch(`/api/search?q=${query}`, { signal: controller.signal })
.then(r => r.json())
.then(setResults)
.catch(err => {
if (err.name !== 'AbortError') console.error(err);
});

// Cleanup: cancel in-flight request on re-run or unmount
return () => controller.abort();
}, [query]); // query is reactive — belongs in deps

Caption: Always return a cleanup function from useEffect when you create subscriptions, timers, or async operations. The cleanup runs before the next effect and on unmount, preventing stale state updates and memory leaks.

useOptimistic — The New Pattern for Optimistic UI

useOptimistic is the idiomatic React 19 way to implement optimistic updates without external libraries.

import { useOptimistic, useTransition } from 'react';

type Todo = { id: string; text: string; done: boolean };

function TodoList({ todos }: { todos: Todo[] }) {
const [optimisticTodos, addOptimisticTodo] = useOptimistic(
todos,
(currentTodos, newTodo: Todo) => [...currentTodos, newTodo]
);

const [isPending, startTransition] = useTransition();

async function handleAdd(text: string) {
const optimistic: Todo = { id: crypto.randomUUID(), text, done: false };

startTransition(async () => {
addOptimisticTodo(optimistic); // Instantly shows the new todo
await createTodo(text); // Server call happens in background
});
}

return (

{optimisticTodos.map(todo => (

{todo.text}

))}

);
}

Caption: useOptimistic applies an optimistic state immediately, then automatically reverts if the async operation throws. This replaces manual patterns involving local state copies and error rollback logic.

Custom Hooks: Building Composable Abstractions

Custom hooks are how you share stateful logic across components. Here is a production-grade useFetch hook:

import { useState, useEffect, useRef, useCallback } from 'react';

interface FetchState<T> {
data: T | null;
error: Error | null;
loading: boolean;
refetch: () => void;
}

function useFetch<T>(url: string): FetchState<T> {
const [data, setData] = useState<T | null>(null);
const [error, setError] = useState<Error | null>(null);
const [loading, setLoading] = useState(true);
const [trigger, setTrigger] = useState(0);

const refetch = useCallback(() => setTrigger(t => t + 1), []);

useEffect(() => {
let cancelled = false;
const controller = new AbortController();

setLoading(true);
setError(null);

fetch(url, { signal: controller.signal })
.then(res => {
if (!res.ok) throw new Error(`HTTP ${res.status}`);
return res.json() as Promise<T>;
})
.then(result => {
if (!cancelled) {
setData(result);
setLoading(false);
}
})
.catch(err => {
if (!cancelled && err.name !== 'AbortError') {
setError(err);
setLoading(false);
}
});

return () => {
cancelled = true;
controller.abort();
};
}, [url, trigger]);

return { data, error, loading, refetch };
}

// Usage
function ProductPage({ id }: { id: string }) {
const { data, loading, error, refetch } = useFetch<Product>(`/api/products/${id}`);

if (loading) return <Skeleton />;
if (error) return <ErrorBanner error={error} onRetry={refetch} />;
return <ProductDetail product={data!} />;
}

Caption: The cancelled flag prevents state updates on unmounted components — a common source of React warnings. The AbortController cancels in-flight network requests when the component unmounts or the URL changes.

4. React Server Components (RSC) Deep Dive {#rsc}

React Server Components are arguably the most significant architectural shift since hooks. They allow components to run exclusively on the server, with zero JavaScript shipped to the client.

The Mental Model

┌─────────────────────────────────────────┐
│ Server │
│ ┌───────────────────────────────────┐ │
│ │ Server Components │ │
│ │ - Access DB, filesystem, secrets│ │
│ │ - No useState, no useEffect │ │
│ │ - Zero JS bundle impact │ │
│ └──────────────┬────────────────────┘ │
│ │ Renders HTML + RSC │
│ │ payload │
└─────────────────┼───────────────────────┘
│
┌─────────────────┼───────────────────────┐
│ Client │
│ ┌────────────────────────────────────┐ │
│ │ Client Components ('use client') │ │
│ │ - useState, useEffect, etc. │ │
│ │ - Event handlers │ │
│ │ - Browser APIs │ │
│ └────────────────────────────────────┘ │
└─────────────────────────────────────────┘

Caption: Server Components and Client Components are rendered in fundamentally different environments. The boundary is explicit: any file with 'use client' at the top is a Client Component. Everything else defaults to Server Component in RSC-enabled frameworks.

A Realistic Server Component

// app/dashboard/page.tsx — Server Component (no 'use client')
import { db } from '@/lib/db';
import { auth } from '@/lib/auth';
import { MetricsGrid } from './MetricsGrid'; // Server Component
import { RealtimeChart } from './RealtimeChart'; // Client Component

// Direct DB access — no API layer needed
async function getDashboardData(userId: string) {
const [metrics, recentOrders] = await Promise.all([
db.metrics.findMany({ where: { userId }, orderBy: { date: 'desc' }, take: 30 }),
db.orders.findMany({ where: { userId }, take: 10, include: { items: true } }),
]);
return { metrics, recentOrders };
}

export default async function DashboardPage() {
const session = await auth(); // Server-only auth check

if (!session) redirect('/login');

const { metrics, recentOrders } = await getDashboardData(session.user.id);

return (

Dashboard
{/* Server Component — renders the grid with zero JS */}
<MetricsGrid metrics={metrics} />
{/* Client Component — receives serializable props from server */}
<RealtimeChart initialData={metrics} userId={session.user.id} />

);
}

Caption: Notice that the database query runs directly in the component — no useEffect, no API call, no loading state. The entire data-fetching and rendering pipeline is server-side. Only RealtimeChart, which needs WebSockets, is a Client Component.

Client Components

// components/RealtimeChart.tsx
'use client'; // This directive marks the Client Component boundary

import { useState, useEffect } from 'react';
import { LineChart, Line, XAxis, YAxis, ResponsiveContainer } from 'recharts';

interface Props {
initialData: Metric[];
userId: string;
}

export function RealtimeChart({ initialData, userId }: Props) {
const [data, setData] = useState(initialData);

useEffect(() => {
const ws = new WebSocket(`wss://api.example.com/metrics/${userId}`);

ws.onmessage = (event) => {
const newPoint: Metric = JSON.parse(event.data);
setData(prev => [...prev.slice(-29), newPoint]);
};

return () => ws.close();
}, [userId]);

return (
<ResponsiveContainer width="100%" height={300}>
<LineChart data={data}>
<XAxis dataKey="date" />
<YAxis />
<Line type="monotone" dataKey="value" stroke="#0A4F5C" />
</LineChart>
</ResponsiveContainer>
);
}

Caption: Server Components can pass serializable data (plain objects, arrays, primitives) as props to Client Components. They cannot pass functions, class instances, or non-serializable values across the boundary.

Composing Server and Client Components

// ✅ Correct — Client Component as a child (slot pattern)
// Server Component
export default function Layout({ children }: { children: React.ReactNode }) {
return (

<ServerSidebar /> {/* Server Component */}
<ClientShell> {/* Client Component */}
{children} {/* Server-rendered content passed as children */}
</ClientShell>

);
}

// ❌ Wrong — importing Server Component inside Client Component
'use client';
import { ServerComponent } from './ServerComponent'; // ❌ This will be treated as a Client Component

Caption: The most common RSC mistake is importing a Server Component into a Client Component. Instead, pass Server Components as children or as named props. This preserves the server/client boundary and avoids sending server code to the browser.

5. The React Compiler (Formerly React Forget) {#compiler}

The React Compiler is a build-time tool that automatically memoizes components and hooks. In 2026, it is stable and available as an opt-in Babel/SWC plugin.

Setup

npm install --save-dev babel-plugin-react-compiler

// babel.config.js
module.exports = {
plugins: [
['babel-plugin-react-compiler', {
target: '18', // or '19'
sources: (filename) => filename.includes('/src/'), // Only compile src files
}]
]
};

What the Compiler Does

Before the compiler, you had to manually memoize:

// Before compiler — manual memoization required
import { useMemo, useCallback, memo } from 'react';

const ExpensiveList = memo(function ExpensiveList({ items, onSelect }: Props) {
const sorted = useMemo(
() => [...items].sort((a, b) => a.name.localeCompare(b.name)),
[items]
);

const handleSelect = useCallback(
(id: string) => onSelect(id),
[onSelect]
);

return (

{sorted.map(item => (
handleSelect(item.id)}>
{item.name}

))}

);
});

After the compiler, write idiomatic code and let the compiler optimize:

// After compiler — write naturally, compiler adds memoization
function ExpensiveList({ items, onSelect }: Props) {
// Compiler automatically detects this as an expensive computation
const sorted = [...items].sort((a, b) => a.name.localeCompare(b.name));

return (

{sorted.map(item => (
onSelect(item.id)}>
{item.name}

))}

);
}

Caption: The compiler statically analyzes your component code and inserts the equivalent of useMemo, useCallback, and memo where it determines values are stable between renders. This eliminates an entire class of performance bugs caused by incorrect or missing dependency arrays.

When to Still Use Manual Memoization

Even with the compiler, some cases require manual control:

// Preserve referential identity across renders for external subscriptions
const stableCallback = useCallback(
() => externalLibrary.subscribe(handleEvent),
[] // Explicitly empty — we want this reference to never change
);

// Very expensive computation with large input — be explicit for clarity
const processedData = useMemo(
() => runMillisecondComputation(rawData),
[rawData]
);

6. Concurrent Features: Transitions, Suspense & Deferred Values {#concurrent}

useTransition — Keeping the UI Responsive

useTransition marks state updates as non-urgent, allowing React to interrupt them if the user interacts.

import { useState, useTransition } from 'react';

function SearchPage() {
const [query, setQuery] = useState('');
const [results, setResults] = useState<Product[]>([]);
const [isPending, startTransition] = useTransition();

function handleSearch(e: React.ChangeEvent<HTMLInputElement>) {
const value = e.target.value;
setQuery(value); // Urgent — update the input immediately

startTransition(() => {
// Non-urgent — can be interrupted if user keeps typing
const filtered = searchProducts(value); // Can be expensive
setResults(filtered);
});
}

return (


{isPending && <Spinner />}
<ProductGrid products={results} />

);
}

Caption: Without useTransition, a slow setResults computation would block the input from updating until it completes, making the UI feel laggy. With transitions, React prioritizes the input update and runs the expensive result computation in the background.

Suspense with Data Fetching

import { Suspense } from 'react';

// Each Suspense boundary independently shows its fallback
export default function ProductsPage() {
return (

Products

{/* Shows skeleton while categories load */}
<Suspense fallback={<CategorySkeleton />}>
<CategoryNav />
</Suspense>

{/* Shows skeleton while featured products load — independent of above */}
<Suspense fallback={<ProductGridSkeleton />}>
<FeaturedProducts />
</Suspense>

{/* Nested Suspense — loads after parent resolves */}
<Suspense fallback={<RecommendationSkeleton />}>
<Suspense fallback={<UserSkeleton />}>
<UserInfo />
</Suspense>
<Recommendations />
</Suspense>

);
}

Caption: Independent Suspense boundaries allow different parts of the page to load in parallel. If CategoryNav and FeaturedProducts both fetch data, their loading states are isolated — one does not block the other.

useDeferredValue — Debouncing Without Timers

import { useState, useDeferredValue } from 'react';

function SearchResults({ query }: { query: string }) {
// Expensive filtering — runs with the deferred (stale) value
const results = filterProducts(query);
return <ProductList products={results} />;
}

function SearchPage() {
const [query, setQuery] = useState('');
const deferredQuery = useDeferredValue(query); // Lags behind query during transitions

const isStale = query !== deferredQuery;

return (

<input
value={query}
onChange={e => setQuery(e.target.value)}
/>

<SearchResults query={deferredQuery} />


);
}

Caption: useDeferredValue is similar to debouncing, but instead of a time delay, it defers to React's scheduler. During a high-priority update (like typing), the deferred value keeps its old value, preventing the expensive SearchResults from re-rendering until the browser is idle.

7. The Actions API: Server Mutations Made Simple {#actions}

The Actions API, stable in React 19, provides a standard way to handle form submissions and server mutations without manual fetch calls or complex state management.

Server Actions

// app/actions/products.ts
'use server'; // This file's exports become server actions

import { db } from '@/lib/db';
import { revalidatePath } from 'next/cache';
import { z } from 'zod';

const CreateProductSchema = z.object({
name: z.string().min(1).max(100),
price: z.number().positive(),
description: z.string().optional(),
});

export async function createProduct(formData: FormData) {
const raw = {
name: formData.get('name'),
price: Number(formData.get('price')),
description: formData.get('description') || undefined,
};

const result = CreateProductSchema.safeParse(raw);

if (!result.success) {
return { error: result.error.flatten().fieldErrors };
}

const product = await db.products.create({ data: result.data });

revalidatePath('/products'); // Invalidate cached product pages
return { success: true, product };
}

Caption: Server Actions run exclusively on the server but are called from Client Components like regular functions. They have access to environment variables, databases, and secrets. Zod validation ensures type safety at the boundary.

Using Actions in Forms

'use client';

import { useActionState } from 'react';
import { createProduct } from '@/app/actions/products';

type ActionState = {
error?: Record<string, string[]>;
success?: boolean;
};

export function CreateProductForm() {
const [state, action, isPending] = useActionState<ActionState, FormData>(
createProduct,
{}
);

return (


Product Name

{state.error?.name && (
{state.error.name[0]}
)}



Price

{state.error?.price && (
{state.error.price[0]}
)}



{isPending ? 'Creating...' : 'Create Product'}


{state.success && Product created successfully! }

);
}

Caption: useActionState (formerly useFormState in React 18) manages the full lifecycle of a form action: pending state, response data, and error handling — all in one hook. This replaces patterns that previously required useState + useEffect + manual fetch logic.

8. State Management in 2026 {#state}

When to Use What

Use Case

Recommended Solution

Local UI state (modals, toggles)

useState

Complex local state

useReducer

Shared state, low frequency updates

React Context

Global state, high frequency updates

Zustand or Jotai

Server/async state

TanStack Query

Form state

React Hook Form

Zustand — Minimal Global State

import { create } from 'zustand';
import { immer } from 'zustand/middleware/immer';
import { persist } from 'zustand/middleware';

interface CartStore {
items: CartItem[];
addItem: (item: CartItem) => void;
removeItem: (id: string) => void;
clearCart: () => void;
totalPrice: () => number;
}

const useCartStore = create<CartStore>()(
persist(
immer((set, get) => ({
items: [],

addItem: (item) => set(state => {
const existing = state.items.find(i => i.id === item.id);
if (existing) {
existing.quantity += 1;
} else {
state.items.push({ ...item, quantity: 1 });
}
}),

removeItem: (id) => set(state => {
state.items = state.items.filter(i => i.id !== id);
}),

clearCart: () => set({ items: [] }),

totalPrice: () =>
get().items.reduce((sum, item) => sum + item.price * item.quantity, 0),
})),
{ name: 'cart-storage' } // Persists to localStorage automatically
)
);

// Usage in component
function CartButton() {
const { items, addItem } = useCartStore();
return addItem(product)}>{items.length} items ;
}

Caption: Zustand with Immer middleware allows you to write "mutating" logic that is actually applied immutably under the hood. The persist middleware automatically syncs state to localStorage with no additional setup.

TanStack Query v5 for Server State

import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';

// Query — declarative data fetching with caching
function ProductList() {
const { data, isLoading, error } = useQuery({
queryKey: ['products'],
queryFn: () => fetch('/api/products').then(r => r.json()),
staleTime: 1000 * 60 * 5, // Data is fresh for 5 minutes
});

if (isLoading) return <Skeleton />;
if (error) return <ErrorMessage error={error} />;

return <Grid products={data} />;
}

// Mutation with cache invalidation
function AddProductButton({ product }: { product: NewProduct }) {
const queryClient = useQueryClient();

const mutation = useMutation({
mutationFn: (data: NewProduct) =>
fetch('/api/products', { method: 'POST', body: JSON.stringify(data) }).then(r => r.json()),
onSuccess: () => {
// Invalidate and refetch products list
queryClient.invalidateQueries({ queryKey: ['products'] });
},
onMutate: async (newProduct) => {
// Cancel outgoing refetches
await queryClient.cancelQueries({ queryKey: ['products'] });

// Snapshot current value
const previous = queryClient.getQueryData(['products']);

// Optimistically update
queryClient.setQueryData(['products'], (old: Product[]) => [...old, newProduct]);

return { previous };
},
onError: (err, newProduct, context) => {
// Rollback on error
queryClient.setQueryData(['products'], context?.previous);
},
});

return (
mutation.mutate(product)} disabled={mutation.isPending}>
{mutation.isPending ? 'Adding...' : 'Add Product'}

);
}

Caption: TanStack Query separates "server state" (data that lives on the server and needs to be kept in sync) from "UI state" (data that only exists on the client). This mental separation dramatically reduces component complexity.

9. Performance Patterns & Optimization {#performance}

Code Splitting with React.lazy

import { lazy, Suspense } from 'react';

// Component is loaded only when first rendered
const HeavyDashboard = lazy(() => import('./HeavyDashboard'));
const VideoPlayer = lazy(() => import('./VideoPlayer'));

function App() {
const [showDashboard, setShowDashboard] = useState(false);

return (

setShowDashboard(true)}>Open Dashboard

{showDashboard && (
<Suspense fallback={<DashboardSkeleton />}>
<HeavyDashboard />
</Suspense>
)}

);
}

Caption: React.lazy enables route-level and component-level code splitting. The bundle for HeavyDashboard is only downloaded when the user triggers it. Combine with Suspense for a seamless loading experience.

Virtualization for Long Lists

import { useVirtualizer } from '@tanstack/react-virtual';
import { useRef } from 'react';

function VirtualList({ items }: { items: Product[] }) {
const parentRef = useRef<HTMLDivElement>(null);

const virtualizer = useVirtualizer({
count: items.length,
getScrollElement: () => parentRef.current,
estimateSize: () => 80, // Estimated row height in px
overscan: 5, // Render 5 extra items above/below viewport
});

return (


{virtualizer.getVirtualItems().map(virtualItem => (
<div
key={virtualItem.key}
style={{
position: 'absolute',
top: 0,
left: 0,
width: '100%',
height: `${virtualItem.size}px`,
transform: `translateY(${virtualItem.start}px)`,
}}
>
<ProductRow product={items[virtualItem.index]} />

))}


);
}

Caption: Without virtualization, rendering a list of 10,000 items creates 10,000 DOM nodes. With @tanstack/react-virtual, only the ~10 items visible in the viewport are rendered at any time, regardless of list size.

Core Web Vitals Targets for 2026

Metric

Good

Needs Improvement

Poor

LCP (Largest Contentful Paint)

< 2.5s

2.5s – 4s

> 4s

FID / INP (Interaction to Next Paint)

< 200ms

200ms – 500ms

> 500ms

CLS (Cumulative Layout Shift)

< 0.1

0.1 – 0.25

> 0.25

TTFB (Time to First Byte)

< 800ms

800ms – 1.8s

> 1.8s

Caption: In 2026, Google's ranking algorithm heavily weights Core Web Vitals. INP (Interaction to Next Paint) replaced FID in March 2024 and measures the responsiveness of all interactions throughout the page lifecycle, not just the first [2].

10. Architecture Patterns for Production Apps {#architecture}

Feature-Based Folder Structure

src/
├── app/ # Next.js App Router pages
│ ├── (auth)/
│ │ ├── login/
│ │ └── register/
│ ├── dashboard/
│ └── products/
│ ├── [id]/
│ └── page.tsx
├── features/ # Feature modules
│ ├── auth/
│ │ ├── components/ # Feature-specific components
│ │ ├── hooks/ # Feature-specific hooks
│ │ ├── actions/ # Server actions
│ │ ├── store.ts # Zustand slice
│ │ └── types.ts
│ ├── products/
│ └── cart/
├── shared/ # Shared across features
│ ├── components/ # Generic UI components
│ ├── hooks/ # Generic hooks
│ ├── lib/ # Third-party lib configs
│ └── types/ # Global types
└── styles/

Caption: Feature-based folder structure co-locates everything related to a feature in one place. This makes it easy to delete a feature entirely (delete one folder), trace data flow, and onboard new developers who can reason about bounded areas of the codebase.

Error Boundaries

'use client';

import { Component, ErrorInfo, ReactNode } from 'react';

interface Props {
children: ReactNode;
fallback?: (error: Error, reset: () => void) => ReactNode;
}

interface State {
error: Error | null;
}

class ErrorBoundary extends Component<Props, State> {
state: State = { error: null };

static getDerivedStateFromError(error: Error): State {
return { error };
}

componentDidCatch(error: Error, info: ErrorInfo) {
// Log to error reporting service
console.error('Error boundary caught:', error, info.componentStack);
reportError({ error, componentStack: info.componentStack });
}

reset = () => this.setState({ error: null });

render() {
if (this.state.error) {
return this.props.fallback
? this.props.fallback(this.state.error, this.reset)
: <DefaultErrorUI error={this.state.error} onReset={this.reset} />;
}
return this.props.children;
}
}

// Usage with render prop
function Dashboard() {
return (
<ErrorBoundary
fallback={(error, reset) => (

Something went wrong: {error.message}
Try Again

)}
>
<DashboardContent />
</ErrorBoundary>
);
}

Caption: Error Boundaries must be class components — React has not provided a hook equivalent. Place Error Boundaries strategically to isolate failures: one per major section, not one at the root. If the root crashes, users see nothing.

11. Testing React Applications in 2026 {#testing}

Testing Philosophy: The Testing Trophy

/\
/ \ E2E (Playwright) — 10%
/ \ — Critical user journeys
/------\
/ \ Integration (Vitest + RTL) — 60%
/ \ — Component + hook behavior
/------------\
/ \ Unit (Vitest) — 30%
/________________\ — Pure functions, utils, reducers

Caption: The Testing Trophy (popularized by Kent C. Dodds) emphasizes integration tests over unit tests for UI code. Test what users do, not implementation details like component state or internal methods [3].

Vitest + React Testing Library

// products/ProductCard.test.tsx
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { describe, it, expect, vi } from 'vitest';
import { ProductCard } from './ProductCard';

const mockProduct: Product = {
id: '1',
name: 'Wireless Keyboard',
price: 79.99,
inStock: true,
image: '/keyboard.jpg',
};

describe('ProductCard', () => {
it('renders product information', () => {
render(<ProductCard product={mockProduct} onAddToCart={() => {}} />);

expect(screen.getByText('Wireless Keyboard')).toBeInTheDocument();
expect(screen.getByText('$79.99')).toBeInTheDocument();
expect(screen.getByRole('button', { name: /add to cart/i })).toBeEnabled();
});

it('calls onAddToCart when button is clicked', async () => {
const user = userEvent.setup();
const handleAddToCart = vi.fn();

render(<ProductCard product={mockProduct} onAddToCart={handleAddToCart} />);

await user.click(screen.getByRole('button', { name: /add to cart/i }));

expect(handleAddToCart).toHaveBeenCalledOnce();
expect(handleAddToCart).toHaveBeenCalledWith(mockProduct);
});

it('disables add to cart when out of stock', () => {
render(
<ProductCard
product={{ ...mockProduct, inStock: false }}
onAddToCart={() => {}}
/>
);

expect(screen.getByRole('button', { name: /out of stock/i })).toBeDisabled();
});
});

Caption: Use userEvent over fireEvent for realistic interaction simulation. userEvent.click dispatches the full sequence of pointer events (pointerdown, mousedown, click) that real users generate, catching bugs that fireEvent.click misses.

Testing Custom Hooks

// hooks/useCart.test.ts
import { renderHook, act } from '@testing-library/react';
import { describe, it, expect } from 'vitest';
import { useCart } from './useCart';

describe('useCart', () => {
it('adds items and calculates total correctly', () => {
const { result } = renderHook(() => useCart());

act(() => {
result.current.addItem({ id: '1', name: 'Widget', price: 10.00 });
result.current.addItem({ id: '2', name: 'Gadget', price: 25.00 });
});

expect(result.current.items).toHaveLength(2);
expect(result.current.total).toBe(35.00);
});
});

Playwright E2E Tests

// e2e/checkout.spec.ts
import { test, expect } from '@playwright/test';

test.describe('Checkout flow', () => {
test('completes a purchase successfully', async ({ page }) => {
// Navigate and add product
await page.goto('/products');
await page.getByRole('button', { name: 'Add to Cart' }).first().click();

// Go to cart
await page.getByRole('link', { name: /cart/i }).click();
await expect(page.getByText('1 item')).toBeVisible();

// Checkout
await page.getByRole('button', { name: 'Checkout' }).click();
await page.getByLabel('Email').fill('test@example.com');
await page.getByLabel('Card Number').fill('4242424242424242');
await page.getByRole('button', { name: 'Pay Now' }).click();

// Confirm
await expect(page.getByText('Order confirmed')).toBeVisible({ timeout: 10000 });
});
});

Caption: Playwright E2E tests run in real browsers (Chromium, Firefox, WebKit) against your deployed application. Use them for critical user journeys — login, checkout, key data entry flows — not for every component interaction.

12. The Ecosystem: Next.js, Remix, TanStack & More {#ecosystem}

Framework Comparison in 2026

Framework

Rendering Model

Best For

RSC Support

Next.js 15+

App Router + RSC

Full-stack apps, e-commerce

✅ Native

Remix v3

Nested routes, loaders

Content-heavy apps

✅ Native

Vite + React

SPA / CSR

Admin dashboards, internal tools

❌ Client only

Astro

Islands architecture

Marketing, content sites

✅ Via integration

TanStack Start

SSR + RSC

Highly type-safe apps

✅ Experimental

Next.js 15 App Router — Key Patterns

// app/products/[id]/page.tsx

// Generate static paths at build time
export async function generateStaticParams() {
const products = await db.products.findMany({ select: { id: true } });
return products.map(p => ({ id: p.id }));
}

// Metadata API — replaces react-helmet
export async function generateMetadata({ params }: { params: { id: string } }) {
const product = await db.products.findUnique({ where: { id: params.id } });
return {
title: product?.name ?? 'Product Not Found',
description: product?.description,
openGraph: {
images: [product?.image ?? '/og-default.jpg'],
},
};
}

export default async function ProductPage({ params }: { params: { id: string } }) {
const product = await db.products.findUnique({ where: { id: params.id } });

if (!product) notFound();

return <ProductDetail product={product} />;
}

Caption: generateStaticParams + async Server Components replaces getStaticPaths + getStaticProps from the Pages Router. The mental model is simpler: the component is the data loader.

13. References {#references}

[1] Greif, S., & Benitte, R. (2025). State of JavaScript 2025. Retrieved from https://stateofjs.com

[2] Google Chrome Team. (2024). Interaction to Next Paint (INP) becomes a Core Web Vital. Web.dev. Retrieved from https://web.dev/blog/inp-cwv-launch

[3] Dodds, K. C. (2021). The Testing Trophy and Testing Classifications. kentcdodds.com. Retrieved from https://kentcdodds.com/blog/the-testing-trophy-and-testing-classifications

[4] React Team. (2024). React 19 Release Candidate. React Blog. Retrieved from https://react.dev/blog/2024/04/25/react-19

[5] React Team. (2024). React Compiler. react.dev. Retrieved from https://react.dev/learn/react-compiler

[6] Abramov, D., & Meta Open Source. (2023). React Server Components RFC. GitHub. Retrieved from https://github.com/reactjs/rfcs/blob/main/text/0188-server-components.md

[7] TanStack. (2025). TanStack Query v5 Documentation. Retrieved from https://tanstack.com/query/latest

[8] Zustand Contributors. (2025). Zustand Documentation. Retrieved from https://docs.pmnd.rs/zustand

[9] Testing Library. (2025). React Testing Library. Retrieved from https://testing-library.com/docs/react-testing-library/intro

[10] Playwright Team. (2025). Playwright Documentation. Retrieved from https://playwright.dev/docs/intro

[11] Next.js Team. (2025). Next.js 15 Documentation. Vercel. Retrieved from https://nextjs.org/docs

Last updated: April 2026. All code examples use React 19+, TypeScript 5.x, and Node.js 22+.