React 19 New Features Explained — Actions, use(), and the Compiler
React has been the dominant UI library for nearly a decade, but one thing always felt clunky: async state. Every form submission, every server request, every loading state required you to manually juggle useState, useEffect, error boundaries, and disabled buttons. For something as common as 'user submits a form', the boilerplate was embarrassing. React 19 ships in 2024 as the most significant release since Hooks in 2016, and it's laser-focused on fixing exactly that.
The problem React 19 solves is the async data lifecycle. Before 19, you needed at minimum three useState calls (data, loading, error) just to handle a single server action — and that's before you even thought about optimistic updates or race conditions. The community built libraries like React Query and SWR specifically to paper over this gap. React 19 pulls the best ideas from those libraries directly into the framework.
By the end of this article you'll understand what React Actions are and why they replace the old pattern, how useActionState and useOptimistic dramatically reduce form-handling boilerplate, what the new use() hook unlocks for async resources, how the React Compiler eliminates the need to manually write useMemo and useCallback, and when to actually reach for each feature in a production codebase.
React Actions — Async State Without the Boilerplate
An Action in React 19 is any function that wraps an async operation and hands it off to React to manage. Think of it as React saying: 'Give me the async work — I'll track whether it's pending, catch your errors, and update state when it's done.'
Before Actions, a form submission looked like this: disable the button manually, set isLoading to true, call await fetch(), set the result, catch the error, set the error state, re-enable the button. Six steps for what should be one. With Actions you pass an async function directly to a form's action prop (or to useActionState), and React handles the pending/error lifecycle automatically.
The mental model shift is important. You stop thinking about loading states as things YOU manage, and start thinking about them as things React observes for you. The useFormStatus hook is a companion piece — it lets any child component inside a form read whether the parent form's action is currently pending, without prop drilling. This is how a Submit button can disable itself without the parent form needing to pass down an isLoading prop.
import { useActionState } from 'react'; // This is the Action — an async function React will manage for us. // It receives the previous state and the FormData from the submission. async function submitContactForm(previousState, formData) { const name = formData.get('name'); const message = formData.get('message'); // Simulate a real API call — could be fetch(), a server action, anything async const response = await fetch('/api/contact', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ name, message }), }); if (!response.ok) { // Returning an object with an error key is the convention for signaling failure return { error: 'Message failed to send. Please try again.' }; } // Returning a success payload — this becomes the new `state` below return { success: true, message: `Thanks ${name}, we'll be in touch!` }; } export default function ContactForm() { // useActionState wires up the action and gives us: // state — the current result (starts as null) // dispatch — the function to call to trigger the action // isPending — true while the async action is running const [state, dispatch, isPending] = useActionState(submitContactForm, null); return ( // Pass dispatch as the form's action — React calls it on submit with FormData <form action={dispatch}> <label> Name <input name="name" type="text" required /> </label> <label> Message <textarea name="message" required /> </label> {/* Show error feedback from the action's return value */} {state?.error && ( <p style={{ color: 'red' }}>{state.error}</p> )} {/* Show success feedback */} {state?.success && ( <p style={{ color: 'green' }}>{state.message}</p> )} {/* isPending comes straight from React — no manual useState needed */} <button type="submit" disabled={isPending}> {isPending ? 'Sending...' : 'Send Message'} </button> </form> ); }
// [ Name input ] [ Message textarea ] [ Send Message button ]
// While submitting (isPending = true):
// [ Name input ] [ Message textarea ] [ Sending... button (disabled) ]
// On success (state = { success: true, message: "Thanks Alex..." }):
// [ Name input ] [ Message textarea ]
// "Thanks Alex, we'll be in touch!" (green)
// [ Send Message button ]
// On error (state = { error: "Message failed to send..." }):
// [ Name input ] [ Message textarea ]
// "Message failed to send. Please try again." (red)
// [ Send Message button ]
useOptimistic — Show the Answer Before the Server Replies
useOptimistic solves a specific, painful UX problem: the gap between a user taking an action and the server confirming it. A like button that takes 400ms to respond feels broken. A todo item that doesn't appear until after a round-trip feels slow. Users in 2024 expect instant feedback.
The hook takes two arguments: the current real state, and an updater function that describes how to compute the optimistic version. When you call setOptimisticValue inside an async action, React immediately shows the optimistic UI. When the action resolves (or rejects), React automatically reverts to whatever the server sent back.
The key insight is that useOptimistic is scoped to the duration of an async transition. You don't manually clean it up. React handles rollback automatically if the server returns an error. This means you get the snappy feel of a local-first app without having to build a full offline-sync system.
import { useOptimistic, useState } from 'react'; // Simulated API call — imagine this is hitting your backend async function toggleLikeOnServer(postId, currentlyLiked) { await new Promise(resolve => setTimeout(resolve, 600)); // Simulate latency // In a real app this would be: await fetch(`/api/posts/${postId}/like`, ...) // For demo purposes, we just return the toggled value return !currentlyLiked; } export default function LikeButton({ postId, initialLikeCount, initiallyLiked }) { // This is the REAL state — what the server has confirmed const [isLiked, setIsLiked] = useState(initiallyLiked); const [likeCount, setLikeCount] = useState(initialLikeCount); // useOptimistic takes: real value, and a function to compute the optimistic value // The second arg receives (currentState, optimisticValue) — the optimisticValue // is whatever you pass to setOptimisticLiked() below const [optimisticIsLiked, setOptimisticLiked] = useOptimistic( isLiked, (currentIsLiked, newLikedValue) => newLikedValue // Simply replace with the optimistic value ); async function handleLikeToggle() { const nextLikedValue = !isLiked; // This IMMEDIATELY updates the UI — no waiting for the server setOptimisticLiked(nextLikedValue); // Also update the count optimistically in the UI setLikeCount(prev => nextLikedValue ? prev + 1 : prev - 1); try { // Now do the real work — React keeps the optimistic UI until this resolves const confirmedValue = await toggleLikeOnServer(postId, isLiked); // Commit the real server-confirmed value to state setIsLiked(confirmedValue); } catch (error) { // If the server call fails, React automatically reverts optimisticIsLiked // We also need to roll back our manual likeCount update setLikeCount(prev => nextLikedValue ? prev - 1 : prev + 1); console.error('Failed to update like status:', error); } } return ( <button onClick={handleLikeToggle} aria-label={optimisticIsLiked ? 'Unlike post' : 'Like post'} style={{ background: optimisticIsLiked ? '#e0245e' : '#ccc', color: 'white', border: 'none', padding: '8px 16px', borderRadius: '20px', cursor: 'pointer', }} > {/* Use the OPTIMISTIC value for instant visual feedback */} {optimisticIsLiked ? '❤️' : '🤍'} {likeCount} </button> ); }
// [ 🤍 42 ] (grey button)
// Immediately after click — BEFORE server responds:
// [ ❤️ 43 ] (red button) — optimistic update
// 600ms later, server confirms — state stays the same:
// [ ❤️ 43 ] (red button) — now backed by real state
// If server throws an error:
// [ 🤍 42 ] (grey button) — automatically reverted
The new use() Hook — Reading Resources Inline
use() is unlike any hook before it. It can be called conditionally (breaking the rules of hooks), it can be called inside loops, and it can unwrap both Promises and Context objects. It's React saying: 'I'll suspend this component for you while this promise resolves — no useEffect, no useState, no manual async lifecycle.'
For Context, use(MyContext) replaces useContext(MyContext) and works identically — except you can now call it inside an if-block, which lets you bail out of a context read early without restructuring your component.
For Promises, use() integrates with Suspense. You pass a promise directly to use(), and the component suspends (shows the nearest Suspense fallback) until the promise resolves. This is the client-side complement to React Server Components' async/await. The critical caveat: don't create the promise inside the render function — create it outside or via a cache/memo, or you'll create a new promise every render and suspend forever.
import { use, Suspense, createContext } from 'react'; // --- Context example: use() with conditional reads --- const ThemeContext = createContext('light'); function ThemedBadge({ isAdmin }) { // This was IMPOSSIBLE with useContext — you couldn't call it conditionally // Now with use(), we can read context only when we actually need it if (!isAdmin) { return <span className="badge">Member</span>; } // Called conditionally — totally valid with use() const theme = use(ThemeContext); return ( <span className={`badge badge--${theme} badge--admin`}> Admin </span> ); } // --- Promise example: use() with Suspense --- // IMPORTANT: Create the promise OUTSIDE the component. // If you write `const userPromise = fetchUser()` inside UserCard, // it creates a NEW promise every render → infinite suspension loop. const userDataPromise = fetchUserFromAPI(42); // Created once, at module level function UserCard() { // use() suspends this component until the promise resolves. // No useState, no useEffect, no loading check needed here. const user = use(userDataPromise); return ( <div className="user-card"> <h2>{user.name}</h2> <p>{user.email}</p> </div> ); } // The parent wraps with Suspense to handle the loading state export default function ProfilePage() { return ( <ThemeContext.Provider value="dark"> <ThemedBadge isAdmin={true} /> {/* Suspense shows the fallback while UserCard's promise resolves */} <Suspense fallback={ <div className="skeleton" aria-busy="true">Loading profile...</div> }> <UserCard /> </Suspense> </ThemeContext.Provider> ); } // Simulated fetch — in real life this is your API call async function fetchUserFromAPI(userId) { const response = await fetch(`/api/users/${userId}`); if (!response.ok) throw new Error(`User ${userId} not found`); return response.json(); // Returns: { id: 42, name: 'Sarah Chen', email: 'sarah@example.com' } }
// <div class="skeleton" aria-busy="true">Loading profile...</div>
// (The Suspense fallback is shown by the nearest Suspense boundary)
// After the promise resolves:
// <span class="badge badge--dark badge--admin">Admin</span>
// <div class="user-card">
// <h2>Sarah Chen</h2>
// <p>sarah@example.com</p>
// </div>
// If the promise rejects — caught by the nearest ErrorBoundary
The React Compiler — Automatic Memoization Without the Mental Overhead
The React Compiler (previously 'React Forget') is an opt-in build-time tool that automatically inserts useMemo, useCallback, and React.memo optimizations into your code. You write plain, readable JavaScript — the compiler figures out what's referentially stable and what needs memoizing.
Why does this matter? Because manual memoization is one of the most error-prone parts of React development. Developers either over-memoize (wrapping everything in useMemo when it's unnecessary and making code harder to read) or under-memoize (missing the one place that's causing expensive re-renders). The compiler eliminates that entire class of bugs.
The compiler ships as a Babel/SWC plugin in React 19 and is already running on instagram.com in production. It works by analyzing your component's data dependencies at compile time. If a value genuinely can't change between renders given the same props/state, the compiler memoizes it automatically. If it can change, the compiler leaves it reactive. The result: you get the performance of a hand-optimized component with none of the cognitive overhead.
// BEFORE the React Compiler — what you had to write manually import { useMemo, useCallback, memo } from 'react'; const ProductCard = memo(function ProductCard({ product, onAddToCart }) { // memo() prevents re-renders when parent re-renders but props haven't changed return ( <div className="product-card"> <h3>{product.name}</h3> <p>${product.price}</p> <button onClick={() => onAddToCart(product.id)}>Add to Cart</button> </div> ); }); function ProductListBefore({ products, taxRate, userId }) { // useMemo to avoid recalculating prices on every render const productsWithTax = useMemo(() => { return products.map(p => ({ ...p, price: (p.basePrice * (1 + taxRate)).toFixed(2), })); }, [products, taxRate]); // Easy to forget a dependency here // useCallback to keep the function reference stable for memo(ProductCard) const handleAddToCart = useCallback((productId) => { fetch(`/api/cart/${userId}/add`, { method: 'POST', body: JSON.stringify({ productId }), }); }, [userId]); // If you forget userId here, you get a stale closure bug return productsWithTax.map(product => ( <ProductCard key={product.id} product={product} onAddToCart={handleAddToCart} /> )); } // ───────────────────────────────────────────────────── // AFTER the React Compiler — what you write instead // The compiler transforms this into the equivalent of the code above import { useState } from 'react'; function ProductCard({ product, onAddToCart }) { // No memo() wrapper needed — compiler handles it return ( <div className="product-card"> <h3>{product.name}</h3> <p>${product.price}</p> <button onClick={() => onAddToCart(product.id)}>Add to Cart</button> </div> ); } function ProductListAfter({ products, taxRate, userId }) { // No useMemo — compiler sees taxRate and products are dependencies // and automatically memoizes this calculation const productsWithTax = products.map(p => ({ ...p, price: (p.basePrice * (1 + taxRate)).toFixed(2), })); // No useCallback — compiler tracks that userId is the only dependency // and keeps the function reference stable automatically function handleAddToCart(productId) { fetch(`/api/cart/${userId}/add`, { method: 'POST', body: JSON.stringify({ productId }), }); } return productsWithTax.map(product => ( <ProductCard key={product.id} product={product} onAddToCart={handleAddToCart} /> )); } // Both versions produce the SAME runtime behavior and performance. // The compiler version is just dramatically easier to read and maintain.
// <div class="product-card">
// <h3>Mechanical Keyboard</h3>
// <p>$109.89</p> ← basePrice $99 * (1 + 0.11 tax rate)
// <button>Add to Cart</button>
// </div>
// With the compiler: ProductCard does NOT re-render when
// an unrelated piece of parent state changes — same as memo().
// But you wrote zero memoization code yourself.
| Feature | React 18 Approach | React 19 Approach |
|---|---|---|
| Form submission state | 3x useState (data, loading, error) + manual management | useActionState — single hook returns [state, dispatch, isPending] |
| Optimistic UI | Manual rollback logic, error handling, state juggling | useOptimistic — auto-reverts on error, scoped to async transition |
| Reading async data | useEffect + useState + loading guard in JSX | use(promise) inside Suspense boundary — component suspends cleanly |
| Reading Context conditionally | Impossible — useContext can't be called conditionally | use(MyContext) — callable inside if-blocks and loops |
| Performance optimization | Manual useMemo, useCallback, React.memo — easy to get wrong | React Compiler — automated at build time, zero runtime cost |
| Pending state in child components | Pass isLoading prop down (prop drilling) | useFormStatus() — any child reads pending state from nearest form Action |
🎯 Key Takeaways
- React Actions (useActionState) collapse the three-useState pattern for async forms into a single hook — state, dispatch, and isPending are all returned together, and React manages the entire pending/error lifecycle for you.
- useOptimistic is scoped to async transitions — the optimistic value is ONLY visible while the async action is in progress, and React automatically reverts it on error, meaning you never have to write rollback logic manually.
- use() is the first hook that can be called conditionally, but it requires promises to be stable references — create them outside the component or via a cache, never inline inside the render function.
- The React Compiler doesn't change how React works at runtime — it's a build step that emits the useMemo/useCallback code you should have written anyway, so you get the performance benefits of manual memoization from clean, unmemoized source code.
⚠ Common Mistakes to Avoid
- ✕Mistake 1: Creating a promise inside the component and passing it to use() — Every render creates a new Promise object, so use() suspends, React retries the render, a new Promise is created, use() suspends again, and you get an infinite loop with no error message. Fix: declare the promise outside the component at module level, or use a stable cache (like useRef or React Query) to ensure the same Promise instance is returned on every render.
- ✕Mistake 2: Forgetting that useActionState's dispatch is not the same as a regular event handler — Beginners call dispatch(event) from an onClick and pass a SyntheticEvent, not FormData. The action then receives an event object instead of FormData and formData.get() returns null silently. Fix: use useActionState with a
- ✕Mistake 3: Mutating state or props directly inside a component and then enabling the React Compiler — The compiler assumes props and state are immutable to safely reuse memoized values. If you do something like items.push(newItem) and rely on that mutation triggering a re-render, the compiler may cache the old value and your UI silently stops updating. Fix: always use the setter function (setItems([...items, newItem])) and run npx react-compiler-healthcheck on your codebase before opting in to the compiler.
Interview Questions on This Topic
- QWhat problem does useActionState solve that couldn't be solved with useState and useEffect, and what are the three values it returns?
- QHow does useOptimistic differ from simply updating local state immediately and then syncing to the server? What does React handle automatically that you'd have to do manually otherwise?
- QThe new use() hook can be called conditionally — but what critical rule must you follow when passing a Promise to use(), and what happens if you break it?
Frequently Asked Questions
Is React 19 backward compatible with React 18 code?
Yes, with a few caveats. The vast majority of React 18 code runs unchanged on React 19. The main breaking changes involve legacy APIs: ReactDOM.render() (already deprecated) is removed in favor of createRoot(), and some legacy Context API behavior changes. The React team published a full migration guide, and the codemods handle most cases automatically.
Do I need to use React Server Components to benefit from React 19's new features?
No. Actions, useActionState, useOptimistic, and the use() hook all work in pure client-side React apps with no server component setup required. Server Components amplify these features (especially Actions), but every feature in this article runs in a standard Vite or Create React App setup. The React Compiler also works independently of Server Components.
Does the React Compiler replace useMemo and useCallback entirely — should I stop writing them?
If you've enabled the React Compiler, you generally don't need to write useMemo or useCallback manually anymore — the compiler handles it better and more consistently than most developers do by hand. However, the compiler is still opt-in and may skip components that violate the Rules of Hooks. For components the compiler skips, manual memoization still applies. Check the compiler output or use React DevTools to verify a specific component is being optimized.
Written and reviewed by senior developers with real-world experience across enterprise, startup and open-source projects. Every article on TheCodeForge is written to be clear, accurate and genuinely useful — not just SEO filler.