React 19 landed with a quiet revolution in how we handle server actions, forms, and async state. We've spent the last few weeks integrating these features into our production apps, and the difference in developer experience—and code cleanliness—has been substantial. These aren't cosmetic changes; they're the kinds of upgrades that reshape how you think about async flows, form handling, and optimism in the UI.
In this post, we'll walk through the React 19 features I use daily and the real-world lessons we've learned shipping them to users. You'll see exactly where they fit into your workflow and where you need to be careful.
Actions — Async transitions with pending state handled for you
The foundation of React 19's async story is Actions. An Action is a function that runs on the server and handles mutations. The key insight is that React manages the transition for you, automatically tracking pending state without extra wiring.
In a traditional setup, you'd call an async function, manually set loading state, catch errors, and update the UI. With Actions, the transition is baked in:
// Server action
'use server'
export async function submitContactForm(formData) {
const email = formData.get('email');
const message = formData.get('message');
await db.contacts.create({ email, message });
revalidatePath('/contact');
return { success: true };
}
// Client component
'use client'
import { useTransition } from 'react';
import { submitContactForm } from './actions';
export function ContactForm() {
const [isPending, startTransition] = useTransition();
const handleSubmit = (e) => {
e.preventDefault();
startTransition(async () => {
await submitContactForm(new FormData(e.currentTarget));
});
};
return (
<form onSubmit={handleSubmit}>
<input name="email" type="email" required />
<textarea name="message" required />
<button disabled={isPending}>
{isPending ? 'Sending...' : 'Submit'}
</button>
</form>
);
}What we get: React handles pending state automatically. No useState for loading flags. No try-catch boilerplate. The server action runs, and you disable buttons or show spinners based on isPending. Error handling integrates with the Next.js error boundary system, so you don't sprinkle try-catch everywhere.
The real win is in complex workflows. When you're coordinating multiple async steps—validating email, then creating the record, then sending a confirmation—Actions keep the mental model clean.
useActionState — Form state, errors and pending flags in one hook
useActionState is the upgrade to useFormState that many of us have been waiting for. It binds a server action directly to form state and gives you pending, data, and error all at once.
The signature is simple:
const [state, action, pending] = useActionState(
serverAction,
initialState
);Here's where it shines in a real lead-capture scenario:
'use server'
export async function createLead(prevState, formData) {
const email = formData.get('email');
const company = formData.get('company');
// Validate
if (!email.includes('@')) {
return { error: 'Invalid email', success: false };
}
try {
const lead = await db.leads.create({ email, company });
return { success: true, leadId: lead.id };
} catch (err) {
return { error: 'Database error. Try again.', success: false };
}
}
// Client
'use client'
import { useActionState } from 'react';
import { createLead } from './actions';
export function LeadForm() {
const [state, action, pending] = useActionState(
createLead,
{ error: null, success: false, leadId: null }
);
return (
<>
{state.success ? (
<div style={{ color: 'green' }}>
Lead created! ID: {state.leadId}
</div>
) : (
<form action={action}>
<input
name="email"
type="email"
required
disabled={pending}
/>
<input
name="company"
required
disabled={pending}
/>
{state.error && (
<p style={{ color: 'red' }}>{state.error}</p>
)}
<button disabled={pending}>
{pending ? 'Creating...' : 'Add Lead'}
</button>
</form>
)}
</>
);
}The form's action prop is automatically bound to the server action. React wires up pending, catches errors, and returns the next state. No manual fetch. No error state variables. The form just works.
In production, this cuts form boilerplate by roughly 60-70%. Errors are typed and returned from the server. Validation happens in one place. If you've been using libraries like React Hook Form or Formik, useActionState handles the 80% case where you just need to call a server action and show feedback.
useOptimistic — Show the expected result instantly while the action runs
Network latency is unavoidable, but perceived latency isn't. useOptimistic lets you show the UI as if the mutation succeeded before the server confirms it.
Picture a task management app where users toggle a checkbox to mark a task complete. With optimistic updates, the checkbox flips instantly; the server catches up a moment later.
'use server'
export async function toggleTask(taskId, completed) {
await new Promise(r => setTimeout(r, 500)); // simulate network
await db.tasks.update(taskId, { completed });
revalidatePath('/tasks');
}
// Client
'use client'
import { useOptimistic } from 'react';
import { toggleTask } from './actions';
import { useActionState } from 'react';
export function TaskItem({ task }) {
const [optimisticTask, setOptimisticTask] = useOptimistic(task);
const handleToggle = async () => {
// Show the change immediately
setOptimisticTask({
...optimisticTask,
completed: !optimisticTask.completed,
});
// Server catches up in the background
await toggleTask(task.id, !optimisticTask.completed);
};
return (
<div>
<input
type="checkbox"
checked={optimisticTask.completed}
onChange={handleToggle}
/>
<span
style={{
textDecoration: optimisticTask.completed
? 'line-through'
: 'none',
}}
>
{optimisticTask.title}
</span>
</div>
);
}The checkbox responds immediately. The strikethrough applies right away. By the time the 500ms round trip completes, the user has already moved on. If the server rejects (validation, permissions), React rolls back to the previous state.
In our e-commerce work, optimistic updates cut perceived load times by half. List operations, status toggles, and lightweight mutations feel instant.
use() — Read promises and context inside components, even conditionally
The use() hook is deceptively powerful. It unwraps promises and context values inside your components, and it can be called conditionally—something React's older hooks can't do.
This opens up patterns like loading data conditionally based on user input:
'use server'
export async function fetchUserDetails(userId) {
await new Promise(r => setTimeout(r, 300));
return await db.users.findById(userId);
}
// Client
'use client'
import { use, useState } from 'react';
export function UserSearchPanel() {
const [selectedUserId, setSelectedUserId] = useState(null);
const [userPromise, setUserPromise] = useState(null);
const handleSelectUser = async (id) => {
setSelectedUserId(id);
setUserPromise(fetchUserDetails(id));
};
return (
<div>
<input
type="text"
onChange={(e) => {
const id = e.target.value;
if (id) {
handleSelectUser(id);
}
}}
placeholder="Enter user ID"
/>
{userPromise && (
<UserCard promise={userPromise} />
)}
</div>
);
}
function UserCard({ promise }) {
const user = use(promise);
return (
<div>
<h3>{user.name}</h3>
<p>{user.email}</p>
</div>
);
}Notice the conditional aspect: use() is only called if userPromise exists. Traditional hooks like useEffect would fire on every render. With use(), you fetch on demand.
Combine this with context, and you can read async context values inside nested components without prop drilling:
const UserContext = createContext(null);
export function UserProvider({ children }) {
const userPromise = fetchCurrentUser();
return (
<UserContext.Provider value={userPromise}>
{children}
</UserContext.Provider>
);
}
// Deep in the tree
function ProfileHeader() {
const userPromise = use(UserContext);
const user = use(userPromise);
return <header>{user.name}</header>;
}This eliminates the wrapper component pattern and async context libraries. One hook, clean semantics, and it works with Suspense boundaries out of the box.
Common mistakes
- Forgetting to mark server actions with 'use server'. If your action isn't tagged, React won't serialize it correctly, and you'll get runtime errors. Always check the top of the file.
- Mixing optimistic updates with imperative refetching. If you call
setOptimisticTask()and then also manually fetch, you'll race conditions. Let the server action handle the revalidation. - Throwing errors in server actions without catching them. Unhandled errors don't flow to the client cleanly. Always return a state object with an error field instead of throwing.
- Using
use()outside of a Suspense boundary. Promises need a boundary to handle pending state. Wrap your component or set up fallback UI. - Overcomplicating form state with
useActionState. It's designed for simple server-driven forms. If you need complex field-level validation, React Hook Form is still the better choice.
Key takeaways
- Actions and useActionState remove boilerplate. No more useState for pending, no manual fetch wiring. Bind a server action to your form and let React manage the flow.
- useOptimistic makes UIs feel snappy. Show the result instantly, roll back if needed. Users don't wait for the network.
- use() is conditional promise unwrapping. It works in ways useEffect and traditional hooks can't. Use it for on-demand data fetching and async context.
- Error handling lives on the server. Return errors as part of your state, not thrown exceptions. This keeps the contract between client and server clear.
- These features work best together. Use useActionState for forms, useOptimistic for mutations, and use() for context and conditional data loading. They're designed to play nicely.
- Not every form needs them. If your form is complex (many validation rules, conditional fields, interdependent inputs), you may still want React Hook Form. These hooks cover the common case cleanly.






Comments
Loading comments…