React 19 ships four things that change how you write components: Actions (async functions that handle form submissions), useActionState (replaces the verbose useState pattern for form state), useOptimistic (built-in optimistic updates), and the React Compiler (automatic memoization). Claude Code generates React 19 patterns correctly — the new conventions are specific and the upgrade path from React 18 has gotchas.
This guide covers React 19 with Claude Code: Actions, useActionState, useOptimistic, the Compiler, and migration patterns.
React 19 Actions
Before React 19, handling form submissions required manual loading state, error state, and optimistic updates in useState. React 19’s Actions handle this pattern natively.
// React 18 pattern — verbose
function CommentForm({ postId }: { postId: string }) {
const [content, setContent] = useState('');
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const handleSubmit = async (e: FormEvent) => {
e.preventDefault();
setIsLoading(true);
setError(null);
try {
await addComment(postId, content);
setContent('');
} catch (err) {
setError((err as Error).message);
} finally {
setIsLoading(false);
}
};
return (
<form onSubmit={handleSubmit}>
<textarea value={content} onChange={e => setContent(e.target.value)} />
{error && <p>{error}</p>}
<button disabled={isLoading}>{isLoading ? 'Posting...' : 'Post'}</button>
</form>
);
}
// React 19 — Actions handle this automatically
function CommentForm({ postId }: { postId: string }) {
const [state, submitAction, isPending] = useActionState(
async (prevState: ActionState, formData: FormData) => {
const content = formData.get('content') as string;
try {
await addComment(postId, content);
return { success: true, error: null };
} catch (err) {
return { success: false, error: (err as Error).message };
}
},
{ success: false, error: null }
);
return (
<form action={submitAction}> {/* form action — not onSubmit */}
<textarea name="content" required />
{state.error && <p role="alert">{state.error}</p>}
<SubmitButton /> {/* Gets isPending from useFormStatus */}
</form>
);
}
// Separate component so useFormStatus works
function SubmitButton() {
const { pending } = useFormStatus(); // Works within the form
return (
<button type="submit" disabled={pending}>
{pending ? 'Posting...' : 'Post Comment'}
</button>
);
}
useOptimistic
Add a like button that updates instantly without waiting for
the server response.
// src/components/LikeButton.tsx
import { useOptimistic, startTransition } from 'react';
interface LikeState {
liked: boolean;
count: number;
}
export function LikeButton({ postId, initialLiked, initialCount }: {
postId: string;
initialLiked: boolean;
initialCount: number;
}) {
const [actualState, setActualState] = useState<LikeState>({
liked: initialLiked,
count: initialCount,
});
// useOptimistic: shows optimistic value while mutation is in-flight
// Reverts to actualState if mutation fails
const [optimisticState, setOptimisticState] = useOptimistic(
actualState,
(currentState, newLiked: boolean) => ({
liked: newLiked,
count: currentState.count + (newLiked ? 1 : -1),
})
);
const handleLike = async () => {
const newLiked = !optimisticState.liked;
// Update optimistic state immediately
startTransition(() => {
setOptimisticState(newLiked);
});
// Perform actual mutation
try {
const result = newLiked
? await likePost(postId)
: await unlikePost(postId);
// Update actual state with server response
setActualState({ liked: result.liked, count: result.count });
} catch (error) {
// useOptimistic automatically reverts on error
console.error('Like failed:', error);
}
};
return (
<button
onClick={handleLike}
aria-label={optimisticState.liked ? 'Unlike' : 'Like'}
aria-pressed={optimisticState.liked}
>
{optimisticState.liked ? '♥' : '♡'} {optimisticState.count}
</button>
);
}
The React Compiler
Our dashboard re-renders constantly because of missing
useMemo/useCallback. Can the React Compiler fix this?
The React Compiler automatically adds memoization — you no longer need to manually call useMemo and useCallback in most cases.
// Before React Compiler — manual memoization everywhere
function UserDashboard({ userId }: { userId: string }) {
const user = useUser(userId);
// Without useMemo, this recalculates on every render
const stats = useMemo(() =>
calculateStats(user.orders, user.preferences),
[user.orders, user.preferences]
);
// Without useCallback, this is a new function on every render
const handleExport = useCallback(() => {
exportUserData(userId, stats);
}, [userId, stats]);
return <DashboardLayout stats={stats} onExport={handleExport} />;
}
// After React Compiler — memoization handled automatically
// The compiler analyzes dependencies and adds memoization where needed
function UserDashboard({ userId }: { userId: string }) {
const user = useUser(userId);
const stats = calculateStats(user.orders, user.preferences); // Compiler memoizes
const handleExport = () => exportUserData(userId, stats); // Compiler memoizes
return <DashboardLayout stats={stats} onExport={handleExport} />;
}
// Cleaner, less boilerplate — same runtime behavior
Enable the compiler:
// babel.config.js (or vite.config.ts with babel plugin)
module.exports = {
plugins: [
['babel-plugin-react-compiler', {
// target: '19', // Only needed when using React 18 compat mode
}],
],
};
The React Compiler shows eslint errors in one of my components.
Why?
The compiler requires strict React rules — components it can’t safely optimize are skipped with a warning:
// Problem: mutating state directly (breaks compiler assumptions)
function BadComponent() {
const [items, setItems] = useState([]);
const addItem = (item) => {
items.push(item); // Direct mutation — compiler can't track this
setItems(items); // Passes same reference — React won't re-render
};
}
// Fixed: immutable update
function GoodComponent() {
const [items, setItems] = useState([]);
const addItem = (item) => {
setItems(prev => [...prev, item]); // New array — traceable by compiler
};
}
New use() Hook
We have a promise from the parent component.
Read it in a child component with the new use() hook.
// React 19: use() can unwrap promises and context
import { use, Suspense } from 'react';
// Parent creates the promise (in a Server Component or outside component tree)
function PostPage({ postId }: { postId: string }) {
const postPromise = fetchPost(postId); // Don't await — pass the Promise
return (
<Suspense fallback={<PostSkeleton />}>
<PostContent postPromise={postPromise} />
</Suspense>
);
}
// Child reads the promise — Suspense handles loading state
function PostContent({ postPromise }: { postPromise: Promise<Post> }) {
const post = use(postPromise); // Suspends until promise resolves
return (
<article>
<h1>{post.title}</h1>
<div>{post.content}</div>
</article>
);
}
// use() also works conditionally (unlike other hooks)
function UserProfile({ user }: { user: User | null }) {
if (!user) return <SignInPrompt />;
const theme = use(ThemeContext); // Can be called after a conditional!
return <div style={{ color: theme.primary }}>{user.name}</div>;
}
For upgrading to React 19, the breaking changes are minimal: React.FC’s children prop handling changed, refs can now be passed as regular props (forwardRef is deprecated), and ReactDOM.render is fully removed. Claude Code handles the migration diff automatically.
For Next.js App Router with Server Components and React 19’s new patterns, see the Next.js App Router guide. For TanStack Query which integrates with React 19’s transitions for data fetching, see the TanStack Query guide. The Claude Skills 360 bundle includes React 19 upgrade skill sets. Start with the free tier to migrate your components to React 19 patterns.