Back to blog

React 19: the new era

React 19 isn't just an update. It's a shift in how we think about forms, async state, and server interactions. useActionState, useFormStatus, useOptimistic, and use() change everything.

February 4, 202615 min read
ReactReact 19HooksuseActionStateuseOptimistic
React 19: the new era
The Great Collapse - 4 useState calls become 1 useActionState
The Great Collapse - 4 useState calls become 1 useActionState

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 submitted
  • method: 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:

  1. optimisticLikes updates immediately (+1)
  2. Request goes to server
  3. If success: likes updates to real value
  4. If error: optimisticLikes reverts 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:

  1. React calls your action with FormData
  2. The form stays interactive (no page reload)
  3. 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 fieldsformData.get() in actions
useState for loadingisPending from hooks
useState for errorsReturn error state from action
useEffect for submissionsForm action attribute
Manual optimistic updatesuseOptimistic
Context for form statususeFormStatus

The pattern: let React manage the lifecycle, you manage the logic.


The cheat sheet

Hook/APIWhat it doesUse when
useActionStateWraps async action, tracks state + pendingForm submissions, any async action
useFormStatusAccess parent form's statusNested form components (buttons, inputs)
useOptimisticInstant UI updates that revert on errorLikes, adds, any "hopeful" update
use()Read promises or context (conditionally)Data fetching with Suspense, conditional context
<form action>Native async form handlingAny 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:

  1. Daily hooks → useState, useEffect, useContext
  2. Underused hooks → useRef, useMemo, useCallback
  3. Pattern unlockers → useReducer, useLayoutEffect, useImperativeHandle
  4. Forgotten hooks → useId, useDebugValue, useSyncExternalStore, useDeferredValue, useTransition
  5. 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.