React 19 Actions API · `use` Hook · React Compiler — A Practical Guide to Forms & Data Fetching That Cuts useEffect Boilerplate in Half
Target audience for this article: This article is written for those who have experience using React's basic hooks (
useState,useEffect).
If you've been developing with React, you know how repetitive data-fetching code can be. Three useState calls (data, loading, error), async handling inside useEffect, cleanup functions, dependency array management... This pattern is functionally complete, but it creates the irony of having more state management code than actual business logic.
React 19 solves this structural repetition at the framework level. The Actions API, the use hook, and React Compiler each have their own role, but together they form a single paradigm designed to make form handling and data-fetching code noticeably more concise.
The goal is not to completely replace the existing useEffect-based pattern. There are situations where the new APIs have a clear advantage, and there are still situations where the old approach is appropriate. In this article, we'll look at how the three pillars work in real code, and at the end we'll lay out criteria for deciding "when to use the new APIs vs. when to stick with useEffect."
React 19 version info React 19 was released as stable in December 2024 and has since evolved to 19.2 in October 2025. React Compiler is described based on v1.0 stable in October 2025.
Core Concepts
React Compiler — Why You No Longer Need to Write Memoization Manually
React Compiler is a compiler that statically analyzes component code at build time and automatically inserts useMemo, useCallback, and React.memo. In Next.js 16, it can be enabled with a single line in next.config.ts.
// next.config.ts
const nextConfig = {
experimental: {
reactCompiler: true,
},
};
export default nextConfig;The compiler applies optimizations only when a component follows the "Rules of React" (pure functions, hook rules, etc.). Components that violate the rules are skipped individually while the rest continue to compile, so it can be adopted incrementally in existing codebases.
Key point: The introduction of the Compiler does not mean
useMemo/useCallbackdisappear entirely. Manual memoization is still needed for components that are intricately coupled with third-party libraries or for legacy code that violates the Rules of React.
Actions API and useTransition — A New Paradigm for Forms and Async Mutations
In traditional React, handling form submissions required managing state manually inside an onSubmit handler. React 19 introduces the pattern of passing an async function directly to the action prop of an HTML element as <form action={asyncFn}>.
The Actions API uses useTransition internally. useTransition is a lower-level primitive API that manages async state transitions without blocking the UI, and Actions are built directly on top of it. When you need to wrap an async operation in a Transition without a form, you can use useTransition directly.
// Directly controlling an async action with useTransition
const [isPending, startTransition] = useTransition();
function handleUpdate() {
startTransition(async () => {
await updateSomething();
// state update after completion
});
}Here are the three related hooks designed alongside the Actions API.
| Hook | Return Value | Role |
|---|---|---|
useActionState |
[state, dispatch, isPending] |
Encapsulates an async function that receives previous state + formData and returns the next state |
useFormStatus |
{ pending, data, method, action } |
Reads the parent form's pending state inside a component within the form tree, without prop drilling |
useOptimistic |
[optimisticState, addOptimistic] |
Immediately displays an optimistic UI before the server response, then automatically rolls back after the response |
use Hook — Reading Promises and Context at Render Time
use has two characteristics that are fundamentally different from existing hooks.
- It accepts a Promise directly as an argument and can suspend the component.
- It can be called inside conditionals or loops (an exception to existing hook rules).
Below is a comparison of the old pattern versus the pattern using the use hook.
// Old pattern — 3 useState + useEffect
function UserProfile({ id }: { id: string }) {
const [user, setUser] = useState(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
useEffect(() => {
fetchUser(id)
.then(setUser)
.catch(setError)
.finally(() => setLoading(false));
}, [id]);
if (loading) return <Spinner />;
if (error) return <ErrorMessage />;
return <div>{user.name}</div>;
}// React 19 pattern — use() + Suspense + ErrorBoundary
function UserProfile({ userPromise }: { userPromise: Promise<User> }) {
const user = use(userPromise); // suspends until the Promise resolves
return <div>{user.name}</div>;
}
function App({ id }: { id: string }) {
// Important: the Promise must not be recreated on every render.
// Memoize with useMemo so a new Promise is only created when id changes.
const userPromise = useMemo(() => fetchUser(id), [id]);
return (
<Suspense fallback={<Spinner />}>
<ErrorBoundary fallback={<ErrorMessage />}>
<UserProfile userPromise={userPromise} />
</ErrorBoundary>
</Suspense>
);
}Suspense: React's declarative loading state handling mechanism. While any component in the subtree of
<Suspense fallback={...}>is waiting for a Promise, React automatically replaces that subtree with the fallback UI. Theusehook connects directly to this Suspense mechanism.
ErrorBoundary: In the
use(promise)pattern, when a Promise rejects, the nearest ErrorBoundary catches the error and displays the fallback UI. You can use it in a functional style with thereact-error-boundarylibrary.
Practical Application
Let's look at four scenarios to see how the three core concepts combine in real code.
Example 1: Form Handling with Server Action + useActionState
An example of implementing a username edit form in a Next.js App Router environment. The Server Action handles validation, DB updates, and cache invalidation, while the client manages state with useActionState.
// app/actions.ts — Server Action
'use server'
import { z } from 'zod';
import { revalidatePath } from 'next/cache';
import { getSession } from '@/lib/auth'; // replace with the auth library you're using
const schema = z.object({
name: z.string().min(2, 'Name must be at least 2 characters'),
});
type State = {
success: boolean;
name?: string;
error?: string;
};
export async function updateUsername(
prevState: State | null,
formData: FormData
): Promise<State> {
// schema.safeParse: Zod validation result.
// If result.success is false, result.error contains the error details.
const result = schema.safeParse({ name: formData.get('name') });
if (!result.success) {
return { success: false, error: result.error.errors[0].message };
}
const session = await getSession(); // gets the current user ID from the session
await db.user.update({
where: { id: session.userId },
data: { name: result.data.name },
});
revalidatePath('/profile');
return { success: true, name: result.data.name };
}// app/profile/page.tsx — Client Component
'use client'
import { useActionState } from 'react';
import { useFormStatus } from 'react-dom'; // import from react-dom, not react
import { updateUsername } from '../actions';
// useFormStatus only works inside a child component of a parent <form>.
// This is exactly why SubmitButton is extracted as a separate component.
function SubmitButton() {
const { pending } = useFormStatus();
return (
<button type="submit" disabled={pending}>
{pending ? 'Saving...' : 'Save'}
</button>
);
}
export default function ProfileForm() {
const [state, dispatch, isPending] = useActionState(updateUsername, null);
return (
<form action={dispatch}>
<input
name="name"
defaultValue={state?.name}
aria-invalid={!!state?.error}
/>
{state?.error && <p role="alert">{state.error}</p>}
{state?.success && <p>Saved successfully!</p>}
<SubmitButton />
</form>
);
}| Code Point | Description |
|---|---|
useActionState(updateUsername, null) |
Wraps the Server Action and returns [state, dispatch, isPending] |
<form action={dispatch}> |
Native form submit calls dispatch |
useFormStatus — imported from react-dom |
Note that it must be imported from react-dom, not react |
Separating SubmitButton |
Must be a separate component to read the parent form's pending state |
prevState parameter |
The previous state is automatically injected as the first argument of the Server Action |
Example 2: An Instantly Responsive Like Button with useOptimistic
A pattern that updates the UI first without waiting for a server response, and automatically rolls back on failure. Error display logic to inform the user of the failure — even after the automatic rollback — is also included.
import { useOptimistic, useState } from 'react';
function LikeButton({ post }: { post: Post }) {
const [errorMessage, setErrorMessage] = useState<string | null>(null);
const [optimisticLikes, addOptimisticLike] = useOptimistic(
post.likes,
(currentLikes: number, increment: number) => currentLikes + increment
);
async function handleLike() {
setErrorMessage(null);
addOptimisticLike(1); // immediately reflected in UI
try {
await likePost(post.id); // server request
} catch {
// useOptimistic automatically rolls back to the server value when this action completes (or fails).
// In addition to the automatic rollback, it's good practice to display a separate message informing the user of the failure.
setErrorMessage('An error occurred while processing your like. Please try again.');
}
}
return (
<div>
<button onClick={handleLike} aria-label={`${optimisticLikes} likes`}>
♥ {optimisticLikes}
</button>
{errorMessage && (
<p role="alert" style={{ fontSize: '0.875rem', color: '#ef4444' }}>
{errorMessage}
</p>
)}
</div>
);
}Optimistic Update: A UX pattern that preemptively assumes the server request will succeed and changes the UI first.
useOptimisticuses the optimistic value while in a pending state, and automatically replaces it with the actual server value once the Action completes. Even with automatic rollback, error display logic that separately informs the user of the failure is necessary.
Example 3: Conditional Context Reading with the use Hook
Unlike existing useContext, use(Context) can be called inside a conditional. When a Context value isn't needed depending on a condition, you can evaluate the condition first and skip unnecessary subscriptions.
import { createContext, use } from 'react';
const ThemeContext = createContext<Theme | null>(null);
function ThemeIcon({ showTheme }: { showTheme: boolean }) {
// With the old useContext, you'd have to call the hook before this conditional.
// use() can be called inside a conditional, so when showTheme is false, the Context subscription is skipped.
if (!showTheme) return null;
const theme = use(ThemeContext);
if (!theme) return null;
return <Icon color={theme.primary} />;
}This characteristic is most meaningful in components where the Context subscription itself is conditional. With the old useContext, you always had to call the hook at the top level regardless of the condition, but use allows subscribing only when it's actually needed, enabling more natural expression of rendering logic.
Example 4: Hybrid Pattern — Server Component + TanStack Query
Prerequisites: This example assumes a basic understanding of Next.js App Router and TanStack Query v5.
This is a pattern that has become established in the industry as of 2025. It prefetches initial data in a Server Component to eliminate client-side waterfalls, and reuses TanStack Query's caching and revalidation on the client.
// app/products/page.tsx — Server Component
import { dehydrate, HydrationBoundary, QueryClient } from '@tanstack/react-query';
import { ProductList } from './ProductList';
import { fetchProducts } from '@/lib/api';
// fetchProducts in a server environment calls the DB or an internal service directly.
// Unlike in a browser environment, it may require an absolute URL or auth header handling.
export default async function ProductsPage() {
const queryClient = new QueryClient();
// Pre-fetches data on the server and populates the queryClient.
await queryClient.prefetchQuery({
queryKey: ['products'],
queryFn: fetchProducts,
});
return (
<HydrationBoundary state={dehydrate(queryClient)}>
<ProductList />
</HydrationBoundary>
);
}// app/products/ProductList.tsx — Client Component
'use client'
import { useQuery } from '@tanstack/react-query';
import { fetchProducts } from '@/lib/api';
// Note: fetchProducts called on the client must be an HTTP request
// that works in a browser environment. (Must be configured for the client environment, including baseURL, auth headers, etc.)
export function ProductList() {
// Reuses the cache prefetched on the server as-is — no additional network request on first render
// Afterward, the client refreshes when staleTime has passed or a refetch condition is met.
const { data: products } = useQuery({
queryKey: ['products'],
queryFn: fetchProducts,
});
return (
<ul>
{products?.map(p => <li key={p.id}>{p.name}</li>)}
</ul>
);
}The key to this pattern is that the server and client share a cache using the same queryKey. The cache pre-populated on the server is serialized (dehydrate) and passed to the client (HydrationBoundary), where the client receives it and renders immediately without additional requests.
Pros and Cons Analysis
Advantages
| Item | Details |
|---|---|
| Boilerplate elimination | The 3-useState pattern for loading, error, and optimistic state is gone |
| Declarative UI | Data-fetching logic integrates naturally into the render flow with use(promise) |
| Automatic optimization | React Compiler handles memoization, reducing the potential for performance bugs |
| Improved form UX | The useActionState + useFormStatus combination handles submission state UI structurally |
| Incremental adoption | Can be applied starting from new components without rewriting existing code all at once |
Disadvantages and Caveats
| Item | Details | Mitigation |
|---|---|---|
| Promise recreation problem | Calling use(fetchUser(id)) directly inside a component body creates a new Promise on every render → infinite Suspense loop |
Client: memoize with useMemo. Server: use the cache() function |
| Compiler exclusions | Compile optimization not applied to components that violate Rules of React or have complex third-party coupling | Manually maintain useMemo/useCallback for those components |
useEffect still has a role |
useEffect remains appropriate for event listeners, WebSocket subscriptions, DOM synchronization, etc. |
Clearly distinguish between data fetching and side effects when applying |
| Server/client boundary | Server Actions only accept serializable arguments (functions and class instances cannot be passed) | Design data passed as primitive types using a DTO pattern |
| Learning curve | Adaptation time needed for first-time design of the Actions + Suspense + ErrorBoundary triple combination | Recommended to start with one small form and gradually internalize the pattern |
What is the
cache()function: A memoization function exclusive to React Server Components, imported asimport { cache } from 'react'. It caches function calls with the same arguments within the same render request, and is used to solve the Promise recreation problem in Server Components with theuse(promise)pattern. On the client,useMemoserves the same role.
The Most Common Mistakes in Practice
- Calling
use(fetchData())directly inside the component body: A new Promise is created on every render, causing an infinite Suspense loop. In client components, memoize withuseMemo; in Server Components, usecache(). - Calling
useFormStatusinside the same component as the form:useFormStatusonly works inside a child component of a parent<form>. The submit button must be extracted as a separate component, and it must be imported fromreact-dom, notreact. - Attempting to eliminate
useEffectentirely: The Actions API andusehook are specialized for data fetching and form mutations. Side effects like DOM event subscriptions, WebSockets, and external system synchronization are still best handled byuseEffect.
New APIs vs. Existing useEffect — When to Choose What
| Situation | Recommended Approach |
|---|---|
| Form submission, data mutations (create/update/delete) | useActionState + Server Action |
| Buttons requiring an immediate UI response before a server response (like, follow, etc.) | useOptimistic |
| Declaratively displaying data when a component renders | use(promise) + Suspense + ErrorBoundary |
| Client-side caching, revalidation, infinite scroll | TanStack Query (can be used alongside React 19) |
| DOM event subscriptions, WebSocket connections, external system synchronization | useEffect — still the right tool |
| Third-party library initialization, browser API access | useEffect |
| Performance optimization for complex components where the Compiler doesn't apply | Manually maintain useMemo/useCallback |
The new APIs are specialized for "code that fetches or mutates data." useEffect remains valid for "code that needs to run side effects in sync with the component lifecycle." The two domains don't overlap, so they can naturally coexist within a single project.
Closing Thoughts
React 19 is a version that has absorbed state management boilerplate at the framework level, so you can focus solely on "what to display." The Actions API reduces the overhead of forms and async mutations, the use hook reduces the overhead of declarative expression of data fetching, and React Compiler reduces the burden of writing performance optimization code.
There's no need to learn all the new APIs at once. Following these three steps in order will naturally help you internalize the patterns.
- It's recommended to start by converting one existing form to
useActionState. After upgrading withpnpm add react@19 react-dom@19, find a form component using theuseState+onSubmitpattern and convert it touseActionStateand<form action={dispatch}>— you'll immediately feel the difference. Complete the step of separating the Submit button withuseFormStatus, and the overall structure of the Actions API will become clear at a glance. - Try applying
useOptimisticto buttons that need to respond instantly — like likes or follows — and you can see firsthand how much the UX changes without waiting for a server response. Writing the error handling logic (displaying a user message after rollback) alongside it will prepare you for real-world application. - It's best to introduce the
usehook pattern after first solidifying your foundation inSuspense+ErrorBoundary. Add thereact-error-boundarylibrary (pnpm add react-error-boundary) and try converting one data-fetching component to theuse(promise)pattern to learn how to design Suspense boundaries. Remember to memoize the Promise withuseMemo, and be sure to test error cases as well. Deciding which layer to placeSuspenseandErrorBoundaryat is often the most challenging part initially — thinking in terms of "at what scope should the loading/error state be displayed?" will make the design decision easier.
Next article: A deeper dive into the hybrid architecture of TanStack Query v5 and React Server Components — building on the prefetch pattern introduced in this article, we'll cover cache layer design in nested layouts,
staleTimestrategies, and implementing waterfall-free parallel data fetching.
References
- React v19 Official Release Notes | react.dev
- useActionState Official Docs | react.dev
- useTransition Official Docs | react.dev
- React 19.2 Release Notes | react.dev
- React Compiler: Say Goodbye to useMemo and useCallback | Certificates.dev
- React 19 use() Hook Deep Dive | DEV Community
- React 19 Suspense for Data Fetching | Syncfusion Blogs
- Stop Using useEffect for Data Fetching | Medium
- React Server Components + TanStack Query: 2026 Data-Fetching Power Duo | DEV Community
- TanStack Query Advanced SSR Official Docs | tanstack.com
- The Complete Developer Guide to React 19: Async Handling | Callstack