
React 19 isn't just an update. It's a shift in how we think about forms, async state, and server interactions.
For years, we've been juggling useState, useEffect, and third-party libraries to handle forms. Loading states here, error states there, optimistic updates somewhere else. It worked, but it was messy.
React 19 says: "What if we just... handled all of that for you?"
Let's see what's new.
The problem React 19 solves
Here's a typical form in React 18:
function NewsletterForm() {
const [email, setEmail] = useState('');
const [isSubmitting, setIsSubmitting] = useState(false);
const [error, setError] = useState(null);
const [success, setSuccess] = useState(false);
async function handleSubmit(e) {
e.preventDefault();
setIsSubmitting(true);
setError(null);
try {
await subscribeToNewsletter(email);
setSuccess(true);
setEmail('');
} catch (err) {
setError(err.message);
} finally {
setIsSubmitting(false);
}
}
return (
<form onSubmit={handleSubmit}>
<input
value={email}
onChange={e => setEmail(e.target.value)}
disabled={isSubmitting}
/>
<button disabled={isSubmitting}>
{isSubmitting ? 'Subscribing...' : 'Subscribe'}
</button>
{error && <p className="error">{error}</p>}
{success && <p className="success">You're in!</p>}
</form>
);
}
Four useState calls. Manual state transitions. Error handling spread across the function.
Now here's the same form in React 19:
function NewsletterForm() {
const [state, formAction, isPending] = useActionState(
async (prevState, formData) => {
const email = formData.get('email');
try {
await subscribeToNewsletter(email);
return { success: true, error: null };
} catch (err) {
return { success: false, error: err.message };
}
},
{ success: false, error: null }
);
return (
<form action={formAction}>
<input name="email" disabled={isPending} />
<button disabled={isPending}>
{isPending ? 'Subscribing...' : 'Subscribe'}
</button>
{state.error && <p className="error">{state.error}</p>}
{state.success && <p className="success">You're in!</p>}
</form>
);
}
One hook. Automatic pending state. Form data handled natively. This is the React 19 way.
useActionState: the form revolution
useActionState is the centerpiece of React 19's form handling. It wraps an async function and gives you:
- The current state (your return value)
- A form action to pass to
<form action={...}> - A pending boolean
const [state, formAction, isPending] = useActionState(actionFn, initialState);
How the action function works
Your action receives two arguments:
async function myAction(previousState, formData) {
// previousState: the last value you returned
// formData: native FormData from the form
const name = formData.get('name');
// Do async stuff...
return newState; // This becomes the next previousState
}
Real example: todo list
function TodoApp() {
const [todos, addTodo, isPending] = useActionState(
async (currentTodos, formData) => {
const text = formData.get('todo');
const newTodo = await createTodoOnServer(text);
return [...currentTodos, newTodo];
},
[]
);
return (
<>
<form action={addTodo}>
<input name="todo" placeholder="What needs to be done?" />
<button disabled={isPending}>
{isPending ? 'Adding...' : 'Add'}
</button>
</form>
<ul>
{todos.map(todo => <li key={todo.id}>{todo.text}</li>)}
</ul>
</>
);
}
No useState for todos. No useState for loading. No manual form handling. The action manages everything.
useFormStatus: nested form components
You're building a design system. You have a <SubmitButton> component used in many forms. How does it know if its parent form is submitting?
Before React 19: prop drilling or context.
React 19: useFormStatus.
import { useFormStatus } from 'react-dom';
function SubmitButton({ children }) {
const { pending } = useFormStatus();
return (
<button disabled={pending}>
{pending ? 'Submitting...' : children}
</button>
);
}
Now use it in any form:
function ContactForm() {
const [state, formAction] = useActionState(submitContact, null);
return (
<form action={formAction}>
<input name="email" placeholder="Email" />
<textarea name="message" placeholder="Message" />
<SubmitButton>Send</SubmitButton>
</form>
);
}
The button automatically knows when its parent form is submitting. No props needed.
What useFormStatus returns
const { pending, data, method, action } = useFormStatus();
pending: boolean, is the form submitting?data: FormData object being submittedmethod: HTTP method (usually "post")action: the action function
Important: it must be a child
useFormStatus only works in components rendered inside a <form>. This won't work:
// ❌ Wrong: same component as the form
function Form() {
const { pending } = useFormStatus(); // Always false!
return <form>...</form>;
}
// ✅ Right: child component
function Form() {
return (
<form>
<SubmitButton /> {/* useFormStatus works here */}
</form>
);
}
useOptimistic: instant feedback
Users hate waiting. When they click "Like", they want to see the heart fill immediately. Not after the server responds.
That's optimistic UI. And React 19 makes it trivial.
function LikeButton({ postId, initialLikes }) {
const [likes, setLikes] = useState(initialLikes);
const [optimisticLikes, addOptimisticLike] = useOptimistic(
likes,
(current, increment) => current + increment
);
async function handleLike() {
addOptimisticLike(1); // Instant UI update
try {
const newLikes = await likePostOnServer(postId);
setLikes(newLikes); // Confirm with real data
} catch (error) {
// useOptimistic automatically reverts on error
}
}
return (
<button onClick={handleLike}>
❤️ {optimisticLikes}
</button>
);
}
Click the button:
optimisticLikesupdates immediately (+1)- Request goes to server
- If success:
likesupdates to real value - If error:
optimisticLikesreverts automatically
Real example: todo with optimistic add
function TodoList() {
const [todos, setTodos] = useState([]);
const [optimisticTodos, addOptimisticTodo] = useOptimistic(
todos,
(current, newTodo) => [...current, { ...newTodo, pending: true }]
);
async function addTodo(formData) {
const text = formData.get('text');
const tempTodo = { id: crypto.randomUUID(), text, pending: true };
addOptimisticTodo(tempTodo);
const savedTodo = await saveTodoToServer(text);
setTodos(current => [...current, savedTodo]);
}
return (
<>
<form action={addTodo}>
<input name="text" />
<button>Add</button>
</form>
<ul>
{optimisticTodos.map(todo => (
<li key={todo.id} style={{ opacity: todo.pending ? 0.5 : 1 }}>
{todo.text}
</li>
))}
</ul>
</>
);
}
New todos appear instantly (grayed out), then solidify when confirmed.
use(): read promises and context
use() is weird. It's not a hook (no "use" prefix rules), but it's called like one.
It does two things:
1. Read promises (with Suspense)
function UserProfile({ userPromise }) {
const user = use(userPromise); // Suspends until resolved
return <h1>{user.name}</h1>;
}
// Parent wraps in Suspense
function App() {
const userPromise = fetchUser(userId);
return (
<Suspense fallback={<Loading />}>
<UserProfile userPromise={userPromise} />
</Suspense>
);
}
No useEffect. No loading state. Just read the promise, and React handles the rest.
2. Read context conditionally
Unlike useContext, you can call use() inside conditions:
function Component({ showTheme }) {
if (showTheme) {
const theme = use(ThemeContext); // ✅ Works!
return <div className={theme}>Themed</div>;
}
return <div>No theme</div>;
}
With useContext, this would be an error. Hooks can't be called conditionally. But use() can.
Form actions: the <form> upgrade
React 19 adds native support for the action attribute on forms:
<form action={myServerAction}>
<input name="email" />
<button>Submit</button>
</form>
When the form submits:
- React calls your action with FormData
- The form stays interactive (no page reload)
- On success, React automatically resets uncontrolled inputs
This works with Server Actions too (in frameworks like Next.js):
// actions.js
'use server';
export async function subscribe(formData) {
const email = formData.get('email');
await db.subscribers.create({ email });
}
// component.jsx
import { subscribe } from './actions';
function Newsletter() {
return (
<form action={subscribe}>
<input name="email" />
<button>Subscribe</button>
</form>
);
}
The action runs on the server. No API route needed.
The mental model shift
React 19 wants you to think differently:
| Before (React 18) | After (React 19) |
|---|---|
useState for form fields | formData.get() in actions |
useState for loading | isPending from hooks |
useState for errors | Return error state from action |
useEffect for submissions | Form action attribute |
| Manual optimistic updates | useOptimistic |
| Context for form status | useFormStatus |
The pattern: let React manage the lifecycle, you manage the logic.
The cheat sheet
| Hook/API | What it does | Use when |
|---|---|---|
useActionState | Wraps async action, tracks state + pending | Form submissions, any async action |
useFormStatus | Access parent form's status | Nested form components (buttons, inputs) |
useOptimistic | Instant UI updates that revert on error | Likes, adds, any "hopeful" update |
use() | Read promises or context (conditionally) | Data fetching with Suspense, conditional context |
<form action> | Native async form handling | Any form submission |
Should you upgrade?
React 19 is production-ready. But here's my take:
Upgrade now if:
- You're starting a new project
- You're already using Next.js App Router
- Forms are a pain point in your app
Wait if:
- Your app works fine and forms aren't complex
- You rely heavily on libraries that haven't updated yet
- You don't have time to learn the new patterns
The new hooks are optional. Your React 18 code still works. But once you try useActionState, you won't want to go back.
Wrapping up the series
We've covered every React hook:
- Daily hooks → useState, useEffect, useContext
- Underused hooks → useRef, useMemo, useCallback
- Pattern unlockers → useReducer, useLayoutEffect, useImperativeHandle
- Forgotten hooks → useId, useDebugValue, useSyncExternalStore, useDeferredValue, useTransition
- React 19 hooks → useActionState, useFormStatus, useOptimistic, use()
That's the complete toolkit. You now know more about React hooks than 90% of developers.
The question isn't "which hook should I learn?" anymore. It's "which hook solves my problem?"
Happy coding.
Got questions? Found a bug in my code? Hit me up on LinkedIn or check out more on my blog.