BlogWeb Development
Web Development

React 19 Complete Guide: Actions, use() Hook, Server Components, and the New Compiler

React 19 lands the most significant changes since hooks. The new Actions API replaces useTransition boilerplate, the use() hook reads promises and context mid-render, the compiler eliminates manual memoization, and server components mature into a production pattern.

P

Priya Sharma

Full-Stack Developer and open-source contributor with a passion for performance and developer experience.

March 15, 2026
24 min de lectura

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.

P

Priya Sharma

Full-Stack Developer and open-source contributor with a passion for performance and developer experience.

¿Listo para transformar tu infraestructura?

Hablemos sobre cómo podemos ayudarte a lograr resultados similares.