Introduction
React 19 is the most significant release since hooks arrived in React 16.8. It represents a fundamental shift in how we build React applications—moving from client-first to server-first architecture while maintaining full backward compatibility.
The release introduces Server Actions for server-side mutations without API routes, the React Compiler for automatic memoization (no more useMemo/useCallback), the use() hook for reading resources during render, useOptimistic for instant UI feedback, and ref as a prop eliminating the need for forwardRef. Combined with improvements to forms, document metadata, and error handling, React 19 changes nearly every aspect of React development.
This guide covers every major feature with practical code examples, real-world patterns, and migration strategies.
Architecture Overview
React 19 introduces a dual rendering model:
| Aspect | Client Components | Server Components |
|---|---|---|
| Rendered on | Browser | Server |
| Bundle size | Included in JS bundle | Zero client JS |
| State/effects | Yes | No |
| Data fetching | useEffect/use() | Direct async/await |
| Interactivity | Full | None |
| Directive | 'use client' | Default |
// Server Component (default) - zero client JS
async function ProductPage({ id }: { id: string }) {
const product = await db.products.findUnique({ where: { id } });
return (
<div>
<h1>{product.name}</h1>
<p>{product.description}</p>
<AddToCartButton productId={id} /> {/* Client Component */}
</div>
);
}
// Client Component - interactive
'use client'
import { useState } from 'react';
function AddToCartButton({ productId }: { productId: string }) {
const [quantity, setQuantity] = useState(1);
return (
<div>
<input
type="number"
value={quantity}
onChange={e => setQuantity(Number(e.target.value))}
min={1}
/>
<button onClick={() => addToCart(productId, quantity)}>
Add to Cart
</button>
</div>
);
}Core Features
Server Actions
Server Actions let you call server-side functions directly from client components without manually creating API endpoints:
// actions.ts
'use server'
export async function createPost(formData: FormData) {
const title = formData.get('title') as string;
const content = formData.get('content') as string;
// Validate
if (!title || title.length < 3) {
return { error: 'Title must be at least 3 characters' };
}
await db.posts.create({ data: { title, content } });
revalidatePath('/posts');
redirect('/posts');
}// CreatePostForm.tsx
import { createPost } from './actions';
export function CreatePostForm() {
return (
<form action={createPost}>
<input name="title" required />
<textarea name="content" required />
<button type="submit">Create Post</button>
</form>
);
}Server Actions can also be called programmatically:
'use client'
import { createPost } from './actions';
export function QuickPost() {
async function handleQuickPost() {
const formData = new FormData();
formData.set('title', 'Quick Note');
formData.set('content', 'Thoughts...');
const result = await createPost(formData);
if (result?.error) {
alert(result.error);
}
}
return <button onClick={handleQuickPost}>Quick Post</button>;
}useActionState for Form State
useActionState replaces the old useFormState hook and provides a cleaner API for managing form submission state:
import { useActionState } from 'react';
async function signup(prevState: any, formData: FormData) {
const email = formData.get('email');
const password = formData.get('password');
try {
await createUser(email, password);
return { success: true, error: null };
} catch (e) {
return { success: false, error: e.message };
}
}
function SignupForm() {
const [state, formAction, isPending] = useActionState(signup, { error: null });
return (
<form action={formAction}>
<input name="email" type="email" />
<input name="password" type="password" />
<button disabled={isPending}>
{isPending ? 'Signing up...' : 'Sign Up'}
</button>
{state.error && <p className="error">{state.error}</p>}
</form>
);
}useFormStatus
useFormStatus provides the pending state of the parent <form> element:
'use client'
import { useFormStatus } from 'react-dom';
function SubmitButton() {
const { pending, data, method, action } = useFormStatus();
return (
<button disabled={pending} type="submit">
{pending ? (
<span>
<Spinner /> Submitting...
</span>
) : (
'Submit'
)}
</button>
);
}
// Usage in a form
function ContactForm() {
return (
<form action={sendContact}>
<input name="email" type="email" required />
<textarea name="message" required />
<SubmitButton /> {/* Automatically tracks form pending state */}
</form>
);
}useOptimistic for Instant UI Feedback
useOptimistic shows the expected result of an action before the server confirms it:
'use client'
import { useOptimistic } from 'react';
function TodoList({ todos, addTodo }) {
const [optimisticTodos, addOptimisticTodo] = useOptimistic(
todos,
(currentTodos, newTodo: string) => [
...currentTodos,
{ id: Date.now(), text: newTodo, pending: true }
]
);
async function handleSubmit(formData: FormData) {
const text = formData.get('text') as string;
addOptimisticTodo(text); // Instant UI update
await addTodo(text); // Server action (may take time)
}
return (
<>
<form action={handleSubmit}>
<input name="text" required />
<button type="submit">Add</button>
</form>
<ul>
{optimisticTodos.map(todo => (
<li key={todo.id} style={{ opacity: todo.pending ? 0.7 : 1 }}>
{todo.text}
{todo.pending && <span> (saving...)</span>}
</li>
))}
</ul>
</>
);
}The use() Hook
use() reads a resource (Promise or Context) during render, with built-in Suspense integration:
import { use, Suspense } from 'react';
function UserProfile({ userPromise }) {
const user = use(userPromise);
return <h1>{user.name}</h1>;
}
function App() {
const userPromise = fetchUser();
return (
<Suspense fallback={<Loading />}>
<UserProfile userPromise={userPromise} />
</Suspense>
);
}Unlike useEffect, use() can be called inside loops and conditions:
function Comments({ commentPromises }: { commentPromises: Promise<Comment>[] }) {
return (
<div>
{commentPromises.map((promise, i) => (
<Comment key={i} promise={promise} />
))}
</div>
);
}
function Comment({ promise }: { promise: Promise<Comment> }) {
const comment = use(promise); // Can be called in a loop!
return <p>{comment.text}</p>;
}Document Metadata
React 19 handles <title>, <meta>, and <link> tags natively:
function BlogPost({ post }) {
return (
<article>
<title>{post.title} | My Blog</title>
<meta name="description" content={post.summary} />
<link rel="canonical" href={post.url} />
<meta property="og:title" content={post.title} />
<meta property="og:image" content={post.coverImage} />
<h1>{post.title}</h1>
<p>{post.content}</p>
</article>
);
}React hoists these tags to <head> automatically, even when rendered deep in the component tree.
ref as a Prop
No more forwardRef — ref is now a regular prop:
// Before (React 18)
const Input = forwardRef<HTMLInputElement, Props>((props, ref) => (
<input ref={ref} {...props} />
));
// After (React 19)
function Input({ ref, ...props }: Props & { ref: Ref<HTMLInputElement> }) {
return <input ref={ref} {...props} />;
}ref Callback Cleanup
Ref callbacks can now return cleanup functions:
function VideoPlayer({ src }: { src: string }) {
return (
<video
ref={(video) => {
video?.play();
return () => video?.pause(); // Cleanup on unmount
}}
src={src}
/>
);
}
function ResizeObserver({ onResize }: { onResize: (entry: ResizeObserverEntry) => void }) {
return (
<div
ref={(node) => {
if (!node) return;
const observer = new ResizeObserver(entries => {
onResize(entries[0]);
});
observer.observe(node);
return () => observer.disconnect();
}}
/>
);
}The React Compiler
The React Compiler (formerly React Forget) automatically memoizes components and hooks, eliminating manual useMemo, useCallback, and React.memo:
// Before: Manual memoization
function ProductList({ products, onSelect }) {
const expensiveList = useMemo(() => {
return products.filter(p => p.inStock).sort((a, b) => a.price - b.price);
}, [products]);
const handleClick = useCallback((id: string) => {
onSelect(id);
}, [onSelect]);
return (
<ul>
{expensiveList.map(p => (
<ProductItem key={p.id} product={p} onClick={handleClick} />
))}
</ul>
);
}
// After: React Compiler handles memoization automatically
function ProductList({ products, onSelect }) {
const expensiveList = products
.filter(p => p.inStock)
.sort((a, b) => a.price - b.price);
return (
<ul>
{expensiveList.map(p => (
<ProductItem key={p.id} product={p} onClick={onSelect} />
))}
</ul>
);
}Enabling the compiler:
// next.config.js
const nextConfig = {
experimental: {
reactCompiler: true,
},
};Step-by-Step Migration
Step 1: Update Dependencies
npm install react@19 react-dom@19Step 2: Replace ReactDOM.render
// Before (React 18)
import ReactDOM from 'react-dom';
ReactDOM.render(<App />, document.getElementById('root'));
// After (React 19)
import { createRoot } from 'react-dom/client';
createRoot(document.getElementById('root')!).render(<App />);Step 3: Replace forwardRef
// Before
const Input = forwardRef<HTMLInputElement, Props>((props, ref) => (
<input ref={ref} {...props} />
));
// After (React 19) - ref is just a prop
function Input({ ref, ...props }: Props & { ref: Ref<HTMLInputElement> }) {
return <input ref={ref} {...props} />;
}Step 4: Migrate to Server Actions
// Before: API route + client fetch
async function handleSubmit() {
const res = await fetch('/api/posts', {
method: 'POST',
body: JSON.stringify({ title, content }),
});
const data = await res.json();
if (data.error) setError(data.error);
}
// After: Server Action
'use server'
async function createPost(formData: FormData) {
const title = formData.get('title');
await db.posts.create({ data: { title } });
revalidatePath('/posts');
}Step 5: Remove Manual Memoization
// Before
const memoizedValue = useMemo(() => computeExpensive(a, b), [a, b]);
const memoizedCallback = useCallback(() => doSomething(a), [a]);
// After (with React Compiler) - just write plain code
const value = computeExpensive(a, b);
const callback = () => doSomething(a);Real-World Use Case: E-Commerce Checkout
// Server Component: Product page
async function ProductPage({ params }: { params: { id: string } }) {
const product = await db.products.findUnique({
where: { id: params.id },
include: { reviews: true },
});
return (
<div>
<title>{product.name} | Shop</title>
<meta name="description" content={product.description} />
<h1>{product.name}</h1>
<p>{product.description}</p>
<p>${product.price}</p>
<AddToCartForm product={product} />
<Reviews reviews={product.reviews} />
</div>
);
}
// Client Component: Add to cart with optimistic update
'use client'
import { useOptimistic } from 'react';
function AddToCartForm({ product }) {
const [optimisticCount, setOptimisticCount] = useOptimistic(0);
async function addToCart(formData: FormData) {
const quantity = Number(formData.get('quantity'));
setOptimisticCount(prev => prev + quantity);
await serverAddToCart(product.id, quantity);
}
return (
<form action={addToCart}>
<input name="quantity" type="number" defaultValue={1} min={1} />
<button type="submit">Add to Cart</button>
{optimisticCount > 0 && (
<span className="badge">{optimisticCount} in cart</span>
)}
</form>
);
}Best Practices
- Default to Server Components: Only add
'use client'when you need interactivity - Validate Server Actions: Always validate input with zod or similar
- Use
useOptimisticfor instant feedback: Show results before server confirms - Let the React Compiler handle memoization: Remove manual
useMemo/useCallback - Colocate data fetching with components: Fetch in Server Components, not in effects
- Use
use()instead of useEffect for data: Cleaner Suspense integration - Add error boundaries: Wrap Server Action forms in error boundaries
Common Pitfalls
| Pitfall | Impact | Solution |
|---|---|---|
| Server Actions without validation | Security vulnerability | Always validate with zod/yup |
| Missing error boundaries | Crashes on action failure | Wrap with error boundaries |
| Overusing client components | Bloated bundle | Default to server components |
| Mutating state incorrectly in useOptimistic | UI flicker | Use pure reducer functions |
| Calling Server Actions from effects | Unexpected re-renders | Use form actions or event handlers |
| Not using revalidatePath | Stale data after mutations | Always revalidate affected paths |
Performance Optimization
React 19 brings significant performance improvements out of the box:
| Optimization | React 18 | React 19 |
|---|---|---|
| Memoization | Manual useMemo/useCallback | Automatic via Compiler |
| Bundle size | All JS shipped to client | Server Components = 0 JS |
| Form handling | Client-side state + API | Server Actions (less JS) |
| Data fetching | useEffect + loading states | use() with Suspense |
| Streaming | Limited | Full Suspense streaming |
Testing Strategies
// Testing Server Actions
import { createPost } from './actions';
describe('createPost', () => {
it('creates a post with valid data', async () => {
const formData = new FormData();
formData.set('title', 'Test Post');
formData.set('content', 'Test content');
const result = await createPost(formData);
expect(result).toBeUndefined(); // Success = no error
});
it('returns error for short title', async () => {
const formData = new FormData();
formData.set('title', 'ab');
formData.set('content', 'Test content');
const result = await createPost(formData);
expect(result.error).toBe('Title must be at least 3 characters');
});
});
// Testing components with useOptimistic
import { render, screen, fireEvent } from '@testing-library/react';
test('shows optimistic todo immediately', async () => {
render(<TodoList todos={[]} addTodo={jest.fn()} />);
fireEvent.change(screen.getByRole('textbox'), { target: { value: 'New todo' } });
fireEvent.click(screen.getByText('Add'));
expect(screen.getByText('New todo')).toBeInTheDocument();
});Migration Strategy
Migrating from React 18 to React 19 is incremental. Existing React 18 code continues to work without changes. The recommended migration order is:
- Update
reactandreact-domto version 19. Run your test suite and fix any breaking changes (mostly deprecation removals). - Remove
forwardRefwrappers — replace with the newrefprop pattern. - Replace
useEffectdata fetching withuse()or Server Components where appropriate. - Adopt Server Actions for form mutations instead of API routes.
- Enable the React Compiler in your build configuration for automatic memoization.
- Convert eligible components to Server Components to reduce client bundle size.
Each step is independent — you can adopt them one at a time without completing the entire migration. The React team recommends starting with ref as a prop because it's a simple, low-risk change that immediately reduces boilerplate.
React 19 and the Ecosystem
React 19's Server Components and Server Actions require framework support. Next.js App Router, Remix, and experimental React frameworks all support these features. Libraries need to be updated to work with Server Components — any library that uses useState, useEffect, or browser APIs must be marked with 'use client'. Most popular libraries (React Query, Zustand, Framer Motion, React Hook Form) have already been updated for React 19 compatibility. Check your dependency tree before upgrading and update libraries to their latest versions.
The React Compiler works best with code that follows React's rules — no side effects in render, no conditional hook calls, and no mutations of props or state. If your codebase has violations of these rules, the compiler will skip those components and fall back to unoptimized rendering. Use the compiler's ESLint plugin to identify and fix rule violations before enabling the compiler.
Performance Impact
React 19's combined improvements deliver measurable performance gains. Server Components reduce the JavaScript shipped to the client by 30-50% for typical applications. The React Compiler eliminates unnecessary re-renders, improving update performance by 15-30% in benchmarks. Server Actions reduce the number of network requests by handling mutations directly on the server without the overhead of API route serialization and deserialization. The use() hook enables more granular Suspense boundaries, which means users see content sooner as each boundary resolves independently. These improvements compound — an application that adopts all React 19 features sees significantly better Core Web Vitals scores, particularly Largest Contentful Paint and Interaction to Next Paint.
Conclusion
React 19 represents a paradigm shift toward server-first development while maintaining full backward compatibility. Server Actions eliminate the need for API routes for simple mutations, the React Compiler automates optimization, and the use() hook simplifies data reading.
Key takeaways:
- Server Actions simplify form handling and mutations without API routes
- React Compiler automates memoization—write plain code, get optimized output
- use() hook reads resources during render with Suspense integration
- useOptimistic provides instant UI feedback for better UX
- ref as a prop eliminates forwardRef boilerplate
- Server Components reduce bundle size by keeping code on the server
- Migration is incremental—existing React 18 code continues to work
- Performance gains compound when adopting all React 19 features together
Start by upgrading to React 19 and adopting ref as a prop — it's the simplest change with the biggest reduction in boilerplate. Then gradually migrate to Server Components and Server Actions as your framework supports them.
Document Metadata and Async Scripts
React 19 adds native support for document metadata, allowing components to set the page title, meta tags, and link tags without a library like React Helmet:
function BlogPost({ post }) {
return (
<article>
<title>{post.title}</title>
<meta name="description" content={post.summary} />
<meta property="og:title" content={post.title} />
<meta property="og:image" content={post.coverImage} />
<link rel="canonical" href={`https://example.com/posts/${post.slug}`} />
<h1>{post.title}</h1>
<div dangerouslySetInnerHTML={{ __html: post.body }} />
</article>
);
}React hoists these tags to the <head> automatically during rendering. In Server Components, this happens on the server, so the HTML arrives with the correct metadata already in place. This eliminates the flash of incorrect metadata that client-side solutions produce.
React 19 also supports async scripts using the async attribute on <script> tags. These scripts load without blocking rendering and are deduplicated automatically — if the same script appears in multiple components, it only loads once:
function Analytics() {
return <script async src="https://analytics.example.com/script.js" />;
}Combined with Server Components, these features make React 19 capable of producing complete, well-structured HTML documents without relying on external metadata management libraries or custom server-side rendering logic.