What React 19 Actually Changes (And Why It Matters)
React 18 gave us concurrent rendering. React 19 makes it usable without a PhD in concurrent mode semantics. The three big shifts: Actions handle async state transitions without the useTransition + useState + error handling boilerplate stack; the use() hook reads promises directly in render, making Suspense-based data fetching ergonomic; and the React Compiler (formerly React Forget) automatically inserts useMemo/useCallback so you never write them manually again.
This isn't just syntactic sugar. These changes address the most common React pain points: form handling, loading states, error boundaries, and the performance overhead of re-renders. Let's walk through each feature with production-ready examples.
React 19 Installation and Migration
# New project with React 19
npx create-react-app@latest my-app --template typescript
# or with Vite (recommended)
npm create vite@latest my-app -- --template react-ts
cd my-app && npm install react@19 react-dom@19
# Upgrade existing project
npm install react@19 react-dom@19 @types/react@19 @types/react-dom@19
# Check for breaking changes
npx react-codemod update-react-imports .
npx react-codemod remove-context-provider .
# Install React Compiler (Babel plugin)
npm install --save-dev babel-plugin-react-compiler
Actions: The New Way to Handle Forms and Async
Actions replace the verbose pattern of useTransition + useState for pending/error/success states. An Action is any async function passed to a form's action prop or the new useActionState hook.
Before React 19 (verbose pattern)
// React 18: 40+ lines to handle a simple form submission
function UpdateProfileForm() {
const [isPending, startTransition] = useTransition()
const [error, setError] = useState<string | null>(null)
const [success, setSuccess] = useState(false)
async function handleSubmit(e: React.FormEvent<HTMLFormElement>) {
e.preventDefault()
const formData = new FormData(e.currentTarget)
startTransition(async () => {
try {
await updateProfile({
name: formData.get('name') as string,
email: formData.get('email') as string,
})
setSuccess(true)
setError(null)
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to update')
setSuccess(false)
}
})
}
return (
<form onSubmit={handleSubmit}>
<input name="name" />
<input name="email" type="email" />
{error && <p className="error">{error}</p>}
{success && <p className="success">Profile updated!</p>}
<button disabled={isPending}>
{isPending ? 'Saving...' : 'Save'}
</button>
</form>
)
}
React 19: useActionState
// React 19: Same functionality, half the code
import { useActionState } from 'react'
type FormState = { error: string | null; success: boolean }
async function updateProfileAction(
prevState: FormState,
formData: FormData
): Promise<FormState> {
try {
await updateProfile({
name: formData.get('name') as string,
email: formData.get('email') as string,
})
return { error: null, success: true }
} catch (err) {
return {
error: err instanceof Error ? err.message : 'Failed to update',
success: false,
}
}
}
function UpdateProfileForm() {
const [state, action, isPending] = useActionState(
updateProfileAction,
{ error: null, success: false }
)
return (
<form action={action}>
<input name="name" />
<input name="email" type="email" />
{state.error && <p className="error">{state.error}</p>}
{state.success && <p className="success">Profile updated!</p>}
<button disabled={isPending}>
{isPending ? 'Saving...' : 'Save'}
</button>
</form>
)
}
useFormStatus: Reading Parent Form State
import { useFormStatus } from 'react-dom'
// This component can be ANYWHERE inside a <form>
function SubmitButton({ children }: { children: React.ReactNode }) {
const { pending, data, method, action } = useFormStatus()
return (
<button type="submit" disabled={pending}>
{pending ? (
<>
<Spinner className="mr-2" />
Processing...
</>
) : children}
</button>
)
}
// Use anywhere in form tree - no prop drilling!
function CheckoutForm() {
return (
<form action={checkoutAction}>
<CartItems />
<PaymentFields />
<SubmitButton>Complete Purchase</SubmitButton>
</form>
)
}
useOptimistic: Instant UI Feedback
import { useOptimistic, useActionState } from 'react'
type Todo = { id: string; text: string; done: boolean }
function TodoList({ todos }: { todos: Todo[] }) {
const [optimisticTodos, addOptimisticTodo] = useOptimistic(
todos,
(state, newTodo: Todo) => [...state, newTodo]
)
async function addTodoAction(_: unknown, formData: FormData) {
const text = formData.get('text') as string
const tempId = crypto.randomUUID()
// Immediately show in UI
addOptimisticTodo({ id: tempId, text, done: false })
// Actual server call β if it fails, optimistic state reverts
await createTodo({ text })
}
const [, action, isPending] = useActionState(addTodoAction, null)
return (
<div>
<ul>
{optimisticTodos.map(todo => (
<li key={todo.id} style={{ opacity: todo.id.startsWith('temp') ? 0.5 : 1 }}>
{todo.text}
</li>
))}
</ul>
<form action={action}>
<input name="text" placeholder="New todo" />
<button disabled={isPending}>Add</button>
</form>
</div>
)
}
The use() Hook: Reading Resources Mid-Render
The use() hook breaks React's previous rule about hooks β it CAN be called conditionally and inside loops. It reads the current value of a Promise or Context, suspending the component if the Promise isn't resolved yet.
use() with Promises and Suspense
import { use, Suspense } from 'react'
// Create a promise (typically from a server or cache)
async function fetchUser(id: string) {
const res = await fetch(`/api/users/${id}`)
if (!res.ok) throw new Error('User not found')
return res.json() as Promise<User>
}
// Component uses the promise directly
function UserProfile({ userPromise }: { userPromise: Promise<User> }) {
// use() suspends if promise is pending, throws if rejected
const user = use(userPromise)
return (
<div>
<h1>{user.name}</h1>
<p>{user.email}</p>
</div>
)
}
// Parent wraps with Suspense + ErrorBoundary
function UserPage({ userId }: { userId: string }) {
// Promise is created in parent, passed down
const userPromise = fetchUser(userId)
return (
<ErrorBoundary fallback={<UserErrorState />}>
<Suspense fallback={<UserSkeleton />}>
<UserProfile userPromise={userPromise} />
</Suspense>
</ErrorBoundary>
)
}
use() with Context (Conditional Usage)
import { use, createContext } from 'react'
const ThemeContext = createContext<'light' | 'dark'>('light')
// React 19: use() can replace useContext AND be conditional
function ThemedButton({ isSpecial }: { isSpecial: boolean }) {
// Conditional context reading β NOT possible with useContext!
if (isSpecial) {
const theme = use(ThemeContext)
return <button className={`special-${theme}`}>Special</button>
}
return <button>Normal</button>
}
// use() inside a loop
function NotificationList({ ids }: { ids: string[] }) {
return (
<ul>
{ids.map(id => {
// Conditionally use context based on item
const notifContext = use(NotificationContext)
return <li key={id}>{notifContext.get(id)?.message}</li>
})}
</ul>
)
}
React Compiler: Automatic Memoization
The React Compiler analyzes your component tree and automatically inserts the equivalent of useMemo, useCallback, and React.memo where needed. You no longer manually optimize re-renders β the compiler does it at build time.
Setting Up React Compiler
// babel.config.js
module.exports = {
plugins: [
['babel-plugin-react-compiler', {
target: '18', // Compatible with React 18 too!
// Enable for specific files first:
// sources: (filename) => filename.includes('src/components'),
}]
]
}
// vite.config.ts
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
export default defineConfig({
plugins: [
react({
babel: {
plugins: [['babel-plugin-react-compiler', { target: '18' }]]
}
})
]
})
What the Compiler Does Automatically
// Your code (no manual memoization):
function ProductCard({ product, onAddToCart }: Props) {
const formattedPrice = formatCurrency(product.price)
const discountedPrice = product.price * (1 - product.discount)
return (
<div className="card">
<img src={product.image} alt={product.name} />
<h3>{product.name}</h3>
<p>Original: {formattedPrice}</p>
<p>Sale: {formatCurrency(discountedPrice)}</p>
<button onClick={() => onAddToCart(product.id)}>
Add to Cart
</button>
</div>
)
}
// Compiler output (conceptually):
const ProductCard = React.memo(function ProductCard({ product, onAddToCart }: Props) {
const formattedPrice = useMemo(() => formatCurrency(product.price), [product.price])
const discountedPrice = useMemo(
() => product.price * (1 - product.discount),
[product.price, product.discount]
)
const handleAddToCart = useCallback(
() => onAddToCart(product.id),
[onAddToCart, product.id]
)
// ...
})
React Compiler Health Check
# Check if your codebase is Compiler-compatible
npx react-compiler-healthcheck
# Output shows:
# β 142 components successfully analyzed
# β 3 components skipped (rules of hooks violations)
# See: src/legacy/OldComponent.tsx:45 β use-before-define pattern
# ESLint plugin to catch violations early
npm install --save-dev eslint-plugin-react-compiler
# Add to .eslintrc: "plugins": ["react-compiler"]
React 19 Server Components in Practice
Server Components run on the server (or at build time), have direct database/filesystem access, and send zero JavaScript to the client. They're now the default in Next.js 14+ and other React Server Component-enabled frameworks.
Server Component Data Fetching Patterns
// app/dashboard/page.tsx (Next.js App Router)
// This is a Server Component β runs on server, no 'use client' directive
import { db } from '@/lib/database'
import { cache } from 'react'
// cache() deduplicates requests within a single render pass
const getUser = cache(async (userId: string) => {
return db.user.findUnique({ where: { id: userId } })
})
// Parallel data fetching β both start simultaneously
async function DashboardPage({ params }: { params: { userId: string } }) {
const [user, stats, recentActivity] = await Promise.all([
getUser(params.userId),
db.stats.findMany({ where: { userId: params.userId } }),
db.activity.findMany({
where: { userId: params.userId },
take: 10,
orderBy: { createdAt: 'desc' }
})
])
if (!user) notFound()
return (
<main>
{/* Server Component β no JS bundle */}
<UserHeader user={user} />
<StatsGrid stats={stats} />
{/* Client Component for interactivity */}
<ActivityFeed initialData={recentActivity} userId={params.userId} />
</main>
)
}
Server Actions: Forms That Call Server Functions
// app/actions/user.ts
'use server' // All exports are server actions
import { db } from '@/lib/database'
import { revalidatePath } from 'next/cache'
import { redirect } from 'next/navigation'
import { z } from 'zod'
const updateUserSchema = z.object({
name: z.string().min(1).max(100),
bio: z.string().max(500).optional(),
})
export async function updateUser(prevState: unknown, formData: FormData) {
const session = await getSession()
if (!session) throw new Error('Not authenticated')
const parsed = updateUserSchema.safeParse({
name: formData.get('name'),
bio: formData.get('bio'),
})
if (!parsed.success) {
return { error: parsed.error.flatten().fieldErrors }
}
await db.user.update({
where: { id: session.userId },
data: parsed.data,
})
revalidatePath('/dashboard')
redirect('/dashboard?updated=true')
}
// app/profile/edit/page.tsx
import { updateUser } from '@/app/actions/user'
import { useActionState } from 'react'
function EditProfileForm() {
const [state, action, isPending] = useActionState(updateUser, null)
return (
<form action={action}>
<input name="name" />
{state?.error?.name && (
<p className="error">{state.error.name[0]}</p>
)}
<textarea name="bio" />
<SubmitButton>Save Profile</SubmitButton>
</form>
)
}
React 19 Document Metadata API
React 19 natively handles <title>, <meta>, <link>, and <style> tags β no more react-helmet or Next.js-specific APIs needed for basic metadata.
// Any component can now render document metadata
function BlogPost({ post }: { post: Post }) {
return (
<>
{/* React 19 hoists these to <head> automatically */}
<title>{post.title} | My Blog</title>
<meta name="description" content={post.excerpt} />
<meta property="og:title" content={post.title} />
<meta property="og:image" content={post.coverImage} />
<link rel="canonical" href={`https://myblog.com/posts/${post.slug}`} />
{/* Stylesheets with precedence control */}
<link
rel="stylesheet"
href="/styles/blog-post.css"
precedence="default" // React deduplicates and orders these
/>
<article>{/* post content */}</article>
</>
)
}
Breaking Changes and Migration Notes
Removed APIs in React 19
// REMOVED: Legacy Context API
// Before:
class OldComponent extends React.Component {
static contextTypes = { theme: PropTypes.string }
render() { return <div>{this.context.theme}</div> }
}
// After: Use createContext + useContext or use()
// REMOVED: defaultProps on function components
// Before:
function Button({ color = 'blue' }) { /* ... */ }
Button.defaultProps = { color: 'blue' } // β No longer works
// After: Use default parameters (which already worked)
function Button({ color = 'blue' }) { /* ... */ } // β
// REMOVED: ReactDOM.render()
// Before:
ReactDOM.render(<App />, document.getElementById('root'))
// After:
const root = ReactDOM.createRoot(document.getElementById('root')!)
root.render(<App />)
// REMOVED: String refs
// Before: <input ref="myInput" />
// After: <input ref={myRef} /> (useRef)
// CHANGED: ref is now a regular prop (no forwardRef needed)
// Before:
const Input = forwardRef((props, ref) => <input {...props} ref={ref} />)
// After (React 19):
function Input({ ref, ...props }: InputProps & { ref?: React.Ref<HTMLInputElement> }) {
return <input {...props} ref={ref} />
}
Hydration Error Improvements
# React 19 provides diff-style hydration error messages
# Before (React 18):
# Error: Hydration failed because the initial UI does not match what was rendered on the server.
# After (React 19):
# Error: Hydration mismatch in <div>:
# Server: <p class="server-only">Hello</p>
# Client: <p class="client-only">Hello</p>
#
# Diff:
# - class="server-only"
# + class="client-only"
Performance Comparison: React 18 vs 19
Real-world benchmarks from the React team and community:
- List rendering (1000 items): 23% faster with automatic batching improvements
- Form with 10 fields: 67% less boilerplate code with Actions (measured by lines)
- Bundle size with Compiler: Typical apps see 15-30% reduction in re-renders, which translates to measurable FPS improvements in animation-heavy UIs
- Server Components: Zero JS shipped for data-display components β a 20-component dashboard page can drop 40KB of JavaScript
React 19 + TypeScript: Updated Types
// Install updated types
npm install @types/react@19 @types/react-dom@19
// New types to know:
import type {
// Actions
FormEvent, // Updated to work with action prop
// use() hook types
Usable, // Promise<T> | Context<T>
// New props on all elements
ref, // No more forwardRef needed
// Server component types (in @types/react/canary)
JSX,
} from 'react'
// TypeScript 5.5+ discriminated union improvements work great with Actions:
type ActionState =
| { status: 'idle' }
| { status: 'loading' }
| { status: 'success'; data: User }
| { status: 'error'; message: string }
function [state, action] = useActionState<ActionState, FormData>(
updateUserAction,
{ status: 'idle' }
)
Conclusion
React 19 represents a genuine evolution in how we write React applications. The Actions API makes async form handling approachable for any developer. The use() hook makes Suspense-based data fetching intuitive. The compiler eliminates an entire class of performance optimization busywork. And the document metadata API removes the need for third-party head management libraries for the 90% use case.
The migration path is gentle β most React 18 code runs unmodified in React 19, and the codemods handle the most common breaking changes automatically. Start with the compiler on a small project, then adopt Actions for new forms, and you'll wonder how you managed without them.
Priya Sharma
Full-Stack Developer and open-source contributor with a passion for performance and developer experience.