modern-react
Modern React (19.x) Complete Guide
This skill covers React 19.0 through 19.2. It is structured so an LLM can quickly identify what pattern to use, what to avoid, and how to migrate legacy code. Every section follows: what it is → DO this → DON'T do that → migration path.
Table of Contents
- Critical DO / DON'T Quick Reference
- Migration Mapping Table
- New Hooks Deep Dive
- Actions and Forms
- React 19.2 Features
- Server Components
- Server Component DO / DON'T
- Complete Migration Examples
- Common Anti-Patterns to Fix
- Codemod Commands
- Breaking Changes
- Migration Checklist
- Quick Reference Card
Critical DO / DON'T Quick Reference
These tables are the fastest way to understand what modern React expects. When reviewing or writing React code, check here first.
Hooks — DO / DON'T
| DO (Modern React 19) | DON'T (Legacy/Anti-pattern) | Why |
|---|---|---|
useActionState(action, initialState) for form state |
Manual useState + setIsPending + setError for forms |
useActionState handles pending, error, and reset automatically |
useOptimistic(state, updateFn) for instant UI feedback |
Separate optimisticState + manual try/catch rollback |
React auto-rolls back on error, less code, fewer bugs |
useFormStatus() in child components for form pending state |
Prop-drilling isPending from parent form to children |
useFormStatus reads parent form state directly, zero props |
use(MyContext) — can be conditional |
useContext(MyContext) — must be top-level |
use() works inside if/loops/early returns |
use(promise) with <Suspense> for data reading |
useEffect + useState for data fetching |
Suspense-based reading is declarative and avoids race conditions |
useEffectEvent(() => { ... }) for non-reactive Effect logic (19.2) |
Adding values to Effect deps just because they're referenced | useEffectEvent breaks false dependencies without hiding bugs |
Import useActionState from 'react' |
Import useFormState from 'react-dom' |
useFormState is deprecated and removed |
Import useFormStatus from 'react-dom' |
N/A — this is new, no old equivalent | Only hook that stays in react-dom |
Refs — DO / DON'T
| DO (Modern React 19) | DON'T (Legacy/Anti-pattern) | Why |
|---|---|---|
function Input({ ref, ...props }) — ref as regular prop |
forwardRef((props, ref) => ...) wrapper |
forwardRef is unnecessary boilerplate in React 19 |
const myRef = useRef(null) + <input ref={myRef} /> |
<input ref="myInput" /> string refs |
String refs are removed in React 19 — they will throw |
Access ref via myRef.current |
Access ref via this.refs.myInput |
this.refs pattern is removed |
Context — DO / DON'T
| DO (Modern React 19) | DON'T (Legacy/Anti-pattern) | Why |
|---|---|---|
<ThemeContext value={theme}> directly |
<ThemeContext.Provider value={theme}> |
.Provider is no longer needed — Context itself is the provider |
const value = use(MyContext) |
const value = useContext(MyContext) |
use() can be called conditionally; useContext cannot |
Rendering & Mounting — DO / DON'T
| DO (Modern React 19) | DON'T (Legacy/Removed) | Why |
|---|---|---|
createRoot(el).render(<App />) |
ReactDOM.render(<App />, el) |
ReactDOM.render is removed in React 19 |
hydrateRoot(el, <App />) |
ReactDOM.hydrate(<App />, el) |
ReactDOM.hydrate is removed in React 19 |
Native <title>, <meta> in components |
react-helmet or manual document.title |
React 19 hoists metadata natively — no library needed |
<Activity mode="hidden"> to preserve hidden state (19.2) |
{condition && <Component />} to show/hide |
Conditional rendering destroys state; Activity preserves it |
Forms — DO / DON'T
| DO (Modern React 19) | DON'T (Legacy/Anti-pattern) | Why |
|---|---|---|
<form action={formAction}> with useActionState |
<form onSubmit={handleSubmit}> with e.preventDefault() |
Actions handle pending, error, and reset automatically |
| Let React auto-reset form after successful action | Manually clearing each useState('') after submit |
Forms with action functions auto-reset on success |
Use formData.get('name') from FormData |
Controlled inputs with value={name} onChange={...} for every field |
Uncontrolled + FormData is simpler for submission-focused forms |
Combine useActionState + useOptimistic for best UX |
Build separate optimistic state management by hand | The two hooks are designed to work together |
Server Components — DO / DON'T
| DO (Modern React 19) | DON'T (Anti-pattern) | Why |
|---|---|---|
| Keep components as Server Components by default | Add 'use client' to every file |
Server Components have zero bundle cost |
Add 'use client' only when you need hooks/events/browser APIs |
Use 'use client' for data fetching components |
Fetch data on the server; send only the result |
Use 'use server' for Server Actions (data mutations) |
Build separate API routes for every form submission | Server Actions are simpler and type-safe |
| Pass serializable props from Server → Client components | Try to import Server Components inside Client Components | Client Components can only receive serializable data |
| Access databases/APIs directly in Server Components | Create API endpoints just to fetch data for rendering | Server Components run on the server — direct access is the point |
Migration Mapping Table
This table maps every deprecated/removed API to its modern replacement with the exact import change. Use this as a lookup when you encounter legacy code.
| Legacy Pattern | Modern Replacement | Import Change | Codemod |
|---|---|---|---|
ReactDOM.render(<App />, el) |
createRoot(el).render(<App />) |
'react-dom' → 'react-dom/client' |
react/replace-reactdom-render |
ReactDOM.hydrate(<App />, el) |
hydrateRoot(el, <App />) |
'react-dom' → 'react-dom/client' |
react/replace-reactdom-render |
useFormState(action, init) |
useActionState(action, init) |
'react-dom' → 'react' |
react/replace-use-form-state |
useContext(MyContext) |
use(MyContext) |
same 'react' |
react/use-context-hook |
forwardRef((props, ref) => ...) |
function Comp({ ref, ...props }) |
Remove forwardRef import |
react/remove-forward-ref |
<Ctx.Provider value={v}> |
<Ctx value={v}> |
No import change | react/remove-context-provider |
ref="myInput" / this.refs.x |
useRef(null) + ref={myRef} |
No import change | react/replace-string-ref |
import { act } from 'react-dom/test-utils' |
import { act } from 'react' |
'react-dom/test-utils' → 'react' |
react/replace-act-import |
React.createElement('div', props) |
<div {...props} /> (JSX) |
No import change | react/create-element-to-jsx |
import React from 'react' |
import { useState } from 'react' |
Named imports | react/update-react-imports |
React.PropTypes |
import PropTypes from 'prop-types' or TypeScript |
Separate package | react/React-PropTypes-to-prop-types |
React.createFactory('div') |
JSX or createElement |
No import change | react/create-element-to-jsx |
<Helmet><title>X</title></Helmet> |
<title>X</title> directly in component |
Remove react-helmet |
Manual |
New Hooks Deep Dive
useActionState
What it is: A single hook that replaces manual useState + setIsPending + setError boilerplate for form submissions. It tracks the action's return value as state, wraps the action for use with <form action={...}>, and provides an isPending boolean.
API:
import { useActionState } from 'react';
const [state, formAction, isPending] = useActionState(action, initialState);
// action: async (previousState, formData) => newState
// initialState: initial value of state (before first submission)
// state: current state (last return value of action, or initialState)
// formAction: wrapped action to pass to <form action={formAction}>
// isPending: boolean — true while action is running
Example — before and after:
// ---- DON'T: React 18 manual pattern ----
function UpdateName({ name, setName }) {
const [error, setError] = useState(null);
const [isPending, setIsPending] = useState(false);
const handleSubmit = async () => {
setIsPending(true);
const error = await updateName(name);
setIsPending(false);
if (error) setError(error);
};
return (
<button onClick={handleSubmit} disabled={isPending}>Update</button>
);
}
// ---- DO: React 19 useActionState ----
import { useActionState } from 'react';
function ChangeName() {
const [error, submitAction, isPending] = useActionState(
async (previousState, formData) => {
const error = await updateName(formData.get('name'));
if (error) return error;
return null;
},
null
);
return (
<form action={submitAction}>
<input type="text" name="name" />
<button type="submit" disabled={isPending}>Update</button>
{error && <p>{error}</p>}
</form>
);
}
useOptimistic
What it is: Shows an immediate, temporary state update while an async operation runs. If the operation fails, React automatically rolls back to the real state — no manual try/catch needed.
API:
import { useOptimistic } from 'react';
const [optimisticState, addOptimistic] = useOptimistic(state, updateFn);
// state: the actual/confirmed state
// updateFn: (currentState, optimisticValue) => newOptimisticState
// optimisticState: state with optimistic updates applied (reverts on error)
// addOptimistic: function to trigger an optimistic update
Example — before and after:
// ---- DON'T: Manual optimistic + rollback ----
function LikeButton({ initialLikes }) {
const [likes, setLikes] = useState(initialLikes);
const [optimisticLikes, setOptimisticLikes] = useState(initialLikes);
const handleLike = async () => {
setOptimisticLikes(prev => prev + 1);
try {
const newLikes = await likePost();
setLikes(newLikes);
} catch (error) {
setOptimisticLikes(likes); // Manual rollback — easy to forget
}
};
return <button>{optimisticLikes} Likes</button>;
}
// ---- DO: useOptimistic with automatic rollback ----
import { useOptimistic, useState } from 'react';
function LikeButton({ initialLikes }) {
const [likes, setLikes] = useState(initialLikes);
const [optimisticLikes, addOptimisticLike] = useOptimistic(
likes,
(currentLikes, addedLikes) => currentLikes + addedLikes
);
const handleLike = async () => {
addOptimisticLike(1); // Instant UI update
const newLikes = await likePost();
setLikes(newLikes); // Confirm real state
// React auto-rolls back on error — no catch needed
};
return <button>{optimisticLikes} Likes</button>;
}
useFormStatus
What it is: Lets any child component of a <form> read the form's submission status without prop drilling. Must be imported from react-dom.
API:
import { useFormStatus } from 'react-dom';
const { pending, data, method, action } = useFormStatus();
// pending: boolean — is the parent form currently submitting
// data: FormData — the submitted form data
// method: 'get' | 'post'
// action: function — the form's action function
Example — before and after:
// ---- DON'T: Prop drilling isPending ----
function SubmitButton({ isPending }) {
return (
<button type="submit" disabled={isPending}>
{isPending ? 'Submitting...' : 'Submit'}
</button>
);
}
function Form() {
const [isPending, setIsPending] = useState(false);
return <form><SubmitButton isPending={isPending} /></form>;
}
// ---- DO: useFormStatus reads parent form directly ----
import { useFormStatus } from 'react-dom';
function SubmitButton() {
const { pending } = useFormStatus();
return (
<button type="submit" disabled={pending}>
{pending ? 'Submitting...' : 'Submit'}
</button>
);
}
function Form() {
return (
<form action={handleSubmit}>
<input name="email" />
<SubmitButton /> {/* Zero props needed */}
</form>
);
}
Important: The component calling useFormStatus must be rendered inside a <form>. It reads the nearest parent form's status. Calling it in the same component that renders the <form> will not work — it must be a child.
use() Hook
What it is: Reads the value of a Promise or Context during rendering. The key difference from useContext: use() can be called conditionally (inside if statements, loops, after early returns). For Promises, it integrates with <Suspense>.
Reading Promises:
import { use, Suspense } from 'react';
function Comments({ commentsPromise }) {
const comments = use(commentsPromise); // Suspends until resolved
return (
<ul>
{comments.map(comment => (
<li key={comment.id}>{comment.text}</li>
))}
</ul>
);
}
function Page({ commentsPromise }) {
return (
<Suspense fallback={<div>Loading comments...</div>}>
<Comments commentsPromise={commentsPromise} />
</Suspense>
);
}
Reading Context conditionally:
// ---- DON'T: useContext must be top-level, always runs ----
function Component({ condition }) {
const value = useContext(MyContext); // Runs even if not needed
if (!condition) return null;
return <div>{value}</div>;
}
// ---- DO: use() can be conditional ----
function Component({ condition }) {
if (!condition) return null;
const value = use(MyContext); // Only runs when needed
return <div>{value}</div>;
}
Actions and Forms
What Are Actions?
Actions are React 19's pattern for data mutations. Any function that uses async transitions is called an "Action". React automatically manages:
- Pending states —
isPendingis tracked for you - Optimistic updates — via
useOptimistic - Error handling — action return value becomes the state
- Form reset — forms with
action={fn}auto-reset on success
Form Action DO / DON'T
| DO | DON'T | Why |
|---|---|---|
<form action={submitAction}> |
<form onSubmit={(e) => { e.preventDefault(); ... }}> |
Actions handle pending/error/reset automatically |
Return error state from action: return { error: 'msg' } |
throw new Error() inside action |
Thrown errors go to error boundaries; returned errors stay in component state |
Use formData.get('field') to read form values |
Controlled value={x} onChange={...} for submit-only forms |
Uncontrolled inputs + FormData = less state, less re-rendering |
Combine useActionState + useOptimistic |
Build separate optimistic update logic | They are designed as companions |
Complete Action + Optimistic Updates Pattern
This is the recommended pattern for forms that need instant UI feedback:
import { useOptimistic, useActionState, useState } from 'react';
function TodoList({ initialTodos }) {
const [todos, setTodos] = useState(initialTodos);
const [optimisticTodos, addOptimisticTodo] = useOptimistic(
todos,
(state, newTodo) => [...state, { ...newTodo, pending: true }]
);
const [error, submitAction, isPending] = useActionState(
async (prevState, formData) => {
const title = formData.get('title');
addOptimisticTodo({ id: Date.now(), title });
const newTodo = await addTodo(title);
setTodos(prev => [...prev, newTodo]);
return null;
},
null
);
return (
<div>
<form action={submitAction}>
<input name="title" />
<button type="submit" disabled={isPending}>Add</button>
</form>
{error && <p className="error">{error}</p>}
<ul>
{optimisticTodos.map(todo => (
<li key={todo.id} style={{ opacity: todo.pending ? 0.5 : 1 }}>
{todo.title}
</li>
))}
</ul>
</div>
);
}
React 19.2 Features
Activity Component
What it is: Wraps UI that can be shown or hidden while preserving its full state (including form inputs, scroll position, internal component state). Unlike {condition && <Component />} which destroys and remounts, Activity keeps everything alive.
Modes:
'visible'— shows children, mounts effects, normal updates'hidden'— hides children (CSS visibility), unmounts effects, defers updates until idle
import { Activity } from 'react';
// ---- DON'T: Conditional rendering destroys state ----
{showPage && <Page />} // State lost when hidden
// ---- DO: Activity preserves state ----
<Activity mode={showPage ? 'visible' : 'hidden'}>
<Page /> {/* State preserved when hidden */}
</Activity>
Use cases:
// Pre-render tabs before user clicks
<Activity mode="hidden">
<TabContent tab="settings" />
</Activity>
// Preserve form state when navigating away
<Activity mode={isOnFormPage ? 'visible' : 'hidden'}>
<ComplexForm />
</Activity>
// Background loading for faster navigation
<Activity mode="hidden">
<NextPageDataLoader />
</Activity>
useEffectEvent
What it is: Extracts non-reactive logic from Effects. Creates event handlers that always see the latest props/state without being listed as Effect dependencies. This solves the problem of Effects re-running when values change that shouldn't trigger re-subscription.
// ---- DON'T: Effect re-runs on theme change (unnecessary) ----
function ChatRoom({ roomId, theme }) {
useEffect(() => {
const connection = createConnection(serverUrl, roomId);
connection.on('connected', () => {
showNotification('Connected!', theme); // theme forces re-run
});
connection.connect();
return () => connection.disconnect();
}, [roomId, theme]); // Re-subscribes when theme changes!
}
// ---- DO: useEffectEvent breaks the false dependency ----
import { useEffect, useEffectEvent } from 'react';
function ChatRoom({ roomId, theme }) {
const onConnected = useEffectEvent(() => {
showNotification('Connected!', theme); // Always sees latest theme
});
useEffect(() => {
const connection = createConnection(serverUrl, roomId);
connection.on('connected', () => onConnected());
connection.connect();
return () => connection.disconnect();
}, [roomId]); // Only re-runs when roomId changes
}
useEffectEvent DO / DON'T:
| DO | DON'T | Why |
|---|---|---|
| Use for event-like callbacks inside Effects (notifications, logging, analytics) | Use just to silence exhaustive-deps lint warnings | Hiding real dependencies causes bugs |
| Treat it as a "snapshot" of latest values for Effect callbacks | Call it outside of an Effect's context | It's specifically designed for Effect event callbacks |
| Let the Effect depend only on values that should trigger re-setup | Add every referenced value to deps even if it shouldn't re-trigger | That's the problem useEffectEvent solves |
cacheSignal API
For React Server Components — provides an AbortSignal that fires when cache() lifetime ends.
import { cache, cacheSignal } from 'react';
const dedupedFetch = cache(fetch);
async function UserProfile({ userId }) {
const user = await dedupedFetch(
`/api/users/${userId}`,
{ signal: cacheSignal() } // AbortSignal — cancels if render aborts
);
return <div>{user.name}</div>;
}
Signal fires when: render completes successfully, render is aborted, or render fails.
Partial Pre-rendering
Pre-render static parts on the server, resume later for dynamic content:
import { prerender, resume } from 'react-dom/server';
// Step 1: Pre-render static shell
const { prelude, postponed } = await prerender(<App />, {
signal: controller.signal,
});
// Send prelude to client/CDN immediately
// Step 2: Resume with dynamic content later
const resumeStream = await resume(<App />, postponedState);
Server Components
React Server Components (RSC) are stable in React 19. They render on the server and send zero JavaScript to the client.
Benefits:
- Zero bundle size — server components add nothing to client JS
- Direct backend access — query databases/APIs directly
- Automatic code splitting — client components are auto-split
- Improved SEO — content rendered on server
Server Component Example
// This is a Server Component (default — no directive needed)
async function BlogPosts() {
const posts = await db.posts.findMany(); // Direct DB query
return (
<div>
{posts.map(post => (
<article key={post.id}>
<h2>{post.title}</h2>
<p>{post.content}</p>
<LikeButton postId={post.id} /> {/* Client component for interactivity */}
</article>
))}
</div>
);
}
Server Actions
// Server Action — runs on server, called from client
async function createPost(formData: FormData) {
'use server';
const title = formData.get('title');
const content = formData.get('content');
await db.posts.create({ data: { title, content } });
revalidatePath('/posts');
}
// Client Component using Server Action
function CreatePostForm() {
return (
<form action={createPost}>
<input name="title" />
<textarea name="content" />
<button type="submit">Create Post</button>
</form>
);
}
Server Component DO / DON'T
| DO | DON'T | Why |
|---|---|---|
| Keep components as Server Components by default | Add 'use client' everywhere "just in case" |
Every 'use client' adds to the JS bundle |
| Access databases/files/APIs directly in Server Components | Create API routes just to fetch data for rendering | Direct access is the entire point of Server Components |
Use async/await in Server Components |
Use useEffect + useState to fetch data in Server Components |
Server Components support top-level async |
Add 'use client' only for: hooks, event handlers, browser APIs |
Add 'use client' because the component imports something |
The directive is about interactivity, not imports |
| Pass serializable data from Server → Client components | Pass functions, classes, or DOM nodes as props to Client Components | Only JSON-serializable values cross the server/client boundary |
Use 'use server' on functions that mutate data |
Use 'use server' on functions that just read data |
Reading should happen in Server Components directly |
| Import Client Components inside Server Components | Import Server Components inside Client Components | Client → Server import is not supported; pass as children instead |
Render Client Components as children: <ServerComp><ClientComp /></ServerComp> |
Try to use useState or useEffect in Server Components |
Server Components cannot use React hooks |
Server vs Client Component Rules (Summary)
Server Components CAN: access backend resources directly, use async/await, import and render Client Components.
Server Components CANNOT: use browser APIs (window, document), use React hooks (useState, useEffect, etc.), use event handlers (onClick, onChange), use context providers.
To make a Client Component: add 'use client' at the top of the file:
'use client';
import { useState } from 'react';
// Hooks, browser APIs, event handlers allowed here
Common Anti-Patterns to Fix
When reviewing or refactoring React code, watch for these patterns and replace them:
Anti-Pattern 1: Manual Form State Boilerplate
// ---- ANTI-PATTERN: 6 useState calls for one form ----
const [name, setName] = useState('');
const [email, setEmail] = useState('');
const [isSubmitting, setIsSubmitting] = useState(false);
const [error, setError] = useState(null);
const [success, setSuccess] = useState(false);
const handleSubmit = async (e) => {
e.preventDefault();
setIsSubmitting(true);
// ... try/catch/finally with manual cleanup
};
// ---- FIX: useActionState ----
const [state, formAction, isPending] = useActionState(submitFn, null);
// One hook. Pending tracked automatically. Form auto-resets.
Anti-Pattern 2: useEffect for Data Fetching
// ---- ANTI-PATTERN: useEffect + useState for fetching ----
const [data, setData] = useState(null);
const [loading, setLoading] = useState(true);
useEffect(() => {
let cancelled = false;
fetchData().then(d => { if (!cancelled) setData(d); setLoading(false); });
return () => { cancelled = true; };
}, []);
// ---- FIX: Server Component (if using RSC framework) ----
async function DataDisplay() {
const data = await fetchData(); // Direct fetch in Server Component
return <div>{data.value}</div>;
}
// ---- FIX: use() + Suspense (if client-side) ----
function DataDisplay({ dataPromise }) {
const data = use(dataPromise);
return <div>{data.value}</div>;
}
Anti-Pattern 3: forwardRef Wrapper
// ---- ANTI-PATTERN: unnecessary forwardRef ----
const Input = forwardRef((props, ref) => {
return <input ref={ref} className="input" {...props} />;
});
// ---- FIX: ref is a regular prop ----
function Input({ ref, ...props }) {
return <input ref={ref} className="input" {...props} />;
}
Anti-Pattern 4: Context.Provider Nesting
// ---- ANTI-PATTERN: .Provider wrapper ----
<ThemeContext.Provider value={theme}>
<AuthContext.Provider value={auth}>
<App />
</AuthContext.Provider>
</ThemeContext.Provider>
// ---- FIX: Context directly ----
<ThemeContext value={theme}>
<AuthContext value={auth}>
<App />
</AuthContext>
</ThemeContext>
Anti-Pattern 5: useEffect for Derived/Computed State
// ---- ANTI-PATTERN: useEffect to compute derived state ----
const [items, setItems] = useState([]);
const [filteredItems, setFilteredItems] = useState([]);
useEffect(() => {
setFilteredItems(items.filter(i => i.active));
}, [items]);
// ---- FIX: Compute during rendering ----
const [items, setItems] = useState([]);
const filteredItems = items.filter(i => i.active);
// No Effect needed. Recalculates automatically when items changes.
// Use useMemo if the computation is expensive:
// const filteredItems = useMemo(() => items.filter(i => i.active), [items]);
Anti-Pattern 6: Effect Dependencies That Shouldn't Re-trigger
// ---- ANTI-PATTERN: Effect re-runs on every prop change ----
useEffect(() => {
const ws = connect(roomId);
ws.onMessage = (msg) => logMessage(userId, msg); // userId causes re-connect
return () => ws.close();
}, [roomId, userId]); // userId shouldn't re-connect!
// ---- FIX: useEffectEvent (React 19.2) ----
const onMessage = useEffectEvent((msg) => {
logMessage(userId, msg); // Always sees latest userId
});
useEffect(() => {
const ws = connect(roomId);
ws.onMessage = onMessage;
return () => ws.close();
}, [roomId]); // Only roomId triggers re-connect
Complete Migration Examples
Full Form Migration
BEFORE (React 18) — manual everything:
function ContactForm() {
const [name, setName] = useState('');
const [email, setEmail] = useState('');
const [message, setMessage] = useState('');
const [isSubmitting, setIsSubmitting] = useState(false);
const [error, setError] = useState(null);
const [success, setSuccess] = useState(false);
const handleSubmit = async (e) => {
e.preventDefault();
setIsSubmitting(true);
setError(null);
try {
const response = await fetch('/api/contact', {
method: 'POST',
body: JSON.stringify({ name, email, message })
});
if (!response.ok) throw new Error('Failed');
setSuccess(true);
setName('');
setEmail('');
setMessage('');
} catch (err) {
setError(err.message);
} finally {
setIsSubmitting(false);
}
};
return (
<form onSubmit={handleSubmit}>
<input value={name} onChange={e => setName(e.target.value)} placeholder="Name" />
<input value={email} onChange={e => setEmail(e.target.value)} placeholder="Email" />
<textarea value={message} onChange={e => setMessage(e.target.value)} placeholder="Message" />
<button type="submit" disabled={isSubmitting}>Submit</button>
{error && <p className="error">{error}</p>}
{success && <p className="success">Sent!</p>}
</form>
);
}
AFTER (React 19) — useActionState, auto-reset:
import { useActionState } from 'react';
async function submitContact(prevState, formData) {
'use server';
const name = formData.get('name');
const email = formData.get('email');
const message = formData.get('message');
try {
await db.messages.create({ name, email, message });
return { success: true };
} catch (error) {
return { error: 'Failed to send' };
}
}
function ContactForm() {
const [state, formAction, isPending] = useActionState(submitContact, null);
return (
<form action={formAction}>
<input name="name" placeholder="Name" />
<input name="email" placeholder="Email" />
<textarea name="message" placeholder="Message" />
<button type="submit" disabled={isPending}>
{isPending ? 'Sending...' : 'Submit'}
</button>
{state?.error && <p className="error">{state.error}</p>}
{state?.success && <p className="success">Sent!</p>}
</form>
);
}
// 6 useState calls → 1 useActionState. Form auto-resets on success.
Codemod Commands
Run All React 19 Codemods at Once
npx codemod@latest react/19/migration-recipe
npx codemod@latest react/19/migration-recipe --target ./src
Individual Codemods
| Codemod | What It Does |
|---|---|
react/remove-context-provider |
Context.Provider → Context |
react/remove-forward-ref |
Removes forwardRef, ref becomes regular prop |
react/use-context-hook |
useContext → use |
react/replace-use-form-state |
useFormState → useActionState |
react/replace-reactdom-render |
ReactDOM.render → createRoot |
react/replace-string-ref |
String refs → callback refs |
react/replace-act-import |
act import: react-dom/test-utils → react |
react/create-element-to-jsx |
createElement → JSX |
react/update-react-imports |
import React → named imports |
Usage
npx codemod react/remove-context-provider --target <path>
npx codemod react/remove-forward-ref --target <path>
npx codemod react/use-context-hook --target <path>
npx codemod react/replace-use-form-state --target <path>
npx codemod react/replace-reactdom-render --target <path>
npx codemod react/replace-string-ref --target <path>
npx codemod react/replace-act-import --target <path>
npx codemod react/create-element-to-jsx --target <path>
npx codemod react/update-react-imports --target <path>
Legacy Codemods (for older upgrades)
npx codemod react/React-PropTypes-to-prop-types --target <path> # React 15.5+
npx codemod react/rename-unsafe-lifecycles --target <path> # React 16.3+
npx codemod react/pure-component --target <path> # React 16.8+
npx codemod react/manual-bind-to-arrow --target <path> # React 18+
Breaking Changes
Removed APIs — Complete List
| Removed API | Replacement | Will It Throw? |
|---|---|---|
ReactDOM.render |
createRoot |
Yes — runtime error |
ReactDOM.hydrate |
hydrateRoot |
Yes — runtime error |
String refs (ref="string") |
useRef or callback refs |
Yes — runtime error |
React.PropTypes |
prop-types package or TypeScript |
Yes — undefined |
React.createFactory |
JSX or createElement |
Yes — undefined |
element.ref access |
Not accessible | Yes — warning then error |
useFormState (react-dom) |
useActionState (react) |
Yes — import error |
react-dom/test-utils (most APIs) |
import { act } from 'react' |
Yes — import error |
TypeScript Setup for React 19
npm install --save-dev @types/react@^19 @types/react-dom@^19
Key type changes:
- Refs can be passed as regular props (no
forwardReftype wrapper needed) useActionStatehas full generic typinguse()infers return type from Promise/Context- Metadata elements (
<title>,<meta>) are valid JSX
ESLint Setup for React 19
npm install --save-dev eslint-plugin-react-hooks@latest
The updated plugin understands useEffectEvent and won't flag it as a missing dependency.
Migration Checklist
Follow this order when migrating a project to React 19:
[ ] 1. Update React packages to v19
[ ] 2. Run: npx codemod@latest react/19/migration-recipe --target ./src
[ ] 3. Update @types/react and @types/react-dom to v19
[ ] 4. Update eslint-plugin-react-hooks to latest
[ ] 5. Fix any remaining ReactDOM.render → createRoot
[ ] 6. Fix any remaining string refs → useRef
[ ] 7. Remove forwardRef wrappers → ref as regular prop
[ ] 8. Replace Context.Provider → Context directly
[ ] 9. Migrate useContext → use() where conditional reading helps
[ ] 10. Convert manual form state → useActionState
[ ] 11. Add useOptimistic where instant feedback improves UX
[ ] 12. Remove react-helmet → native <title> and <meta>
[ ] 13. Test all forms, actions, and Server Components
[ ] 14. Review for any remaining deprecated API usage
Quick Reference Card
New Hooks
| Hook | Purpose | Import | Version |
|---|---|---|---|
useActionState |
Form submission state + pending | react |
19.0 |
useOptimistic |
Optimistic UI with auto-rollback | react |
19.0 |
useFormStatus |
Read parent form status (no prop drill) | react-dom |
19.0 |
use() |
Read Promise/Context conditionally | react |
19.0 |
useEffectEvent |
Non-reactive Effect event callbacks | react |
19.2 |
New Components & APIs
| Name | Purpose | Version |
|---|---|---|
<Activity> |
Show/hide UI preserving state | 19.2 |
cacheSignal() |
AbortSignal for RSC cache lifetime | 19.2 |
prerender() |
Partial pre-rendering on server | 19.2 |
resume() |
Resume SSR stream after prerender | 19.2 |
Decision Tree: Which Hook to Use
Need to handle form submission?
→ useActionState (manages state + pending + error)
Need instant UI feedback during async operation?
→ useOptimistic (auto-rollback on error)
Need form pending state in a child component?
→ useFormStatus (reads parent form, no prop drilling)
Need to read context conditionally or read a Promise?
→ use() (works in if/loops, integrates with Suspense)
Need to reference latest props/state from inside an Effect without re-triggering?
→ useEffectEvent (19.2) (breaks false dependencies)
Need to show/hide UI without losing state?
→ <Activity> (19.2) (preserves state when hidden)
Best Practices Summary
- Default to Server Components; add
'use client'only when needed - Use
useActionStatefor all form submissions — it replaces 3-5 manual useState calls - Use
useOptimisticalongsideuseActionStatefor instant feedback - Use
useFormStatusto eliminate isPending prop drilling - Use
use()for conditional context reading and Promise-based data loading - Use
useEffectEventfor Effect callbacks that shouldn't trigger re-subscription - Use
<Activity>instead of conditional rendering when state preservation matters - Use native
<title>and<meta>— no helmet library needed - Pass
refas a regular prop — delete allforwardRefwrappers - Use
<Context value={...}>directly — delete all.Providerusage - Run the migration recipe codemod first, then fix remaining issues manually