블로그Web 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 분 읽기

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.

인프라를 혁신할 준비가 되셨습니까?

저희가 어떻게 귀사의 유사한 결과를 달성하도록 도울 수 있는지 논의해 봅시다.