50% off SaaS Starter Kit — only for the first 100 buildersGrab it →
← Back to blog
reactMay 24, 2026·8 min read

React 19 Features — What's Actually Useful in Production

React 19 shipped with a lot of fanfare. Here's what we actually use day-to-day and what you can safely ignore for now.

Robert Seghedi

Robert Seghedi

Co-founder, peal.dev

React 19 Features — What's Actually Useful in Production

React 19 dropped and the internet went predictably bananas. Threads full of 'this changes everything' and blog posts explaining features with todo-list examples that would never survive contact with a real codebase. We've been running React 19 in production for a while now across our templates, so let's cut through it — here's what's actually worth reaching for and what sounds better in a conference talk than it works at 2am during a production incident.

The useTransition + Actions combo is the real deal

Before React 19, if you wanted to handle a form submission with loading states, error handling, and optimistic updates, you were writing a small novel of useState calls. Pending state, error state, success state, some useRef to avoid stale closures — it adds up fast. React 19's Actions concept, combined with the enhanced useTransition, cleans this up in a way that actually makes sense.

The key insight is that async functions passed to startTransition are now treated as 'Actions'. React will track when they're pending, handle errors, and integrate with the new useOptimistic hook. In practice, that means your form logic finally has a home that isn't 'dump everything in a useEffect and pray'.

import { useTransition, useState } from 'react';

function UpdateProfileForm({ userId }: { userId: string }) {
  const [isPending, startTransition] = useTransition();
  const [error, setError] = useState<string | null>(null);
  const [success, setSuccess] = useState(false);

  async function handleSubmit(formData: FormData) {
    setError(null);
    setSuccess(false);

    startTransition(async () => {
      try {
        const res = await fetch(`/api/users/${userId}`, {
          method: 'PATCH',
          body: formData,
        });

        if (!res.ok) {
          const data = await res.json();
          setError(data.message ?? 'Something went wrong');
          return;
        }

        setSuccess(true);
      } catch (e) {
        setError('Network error — try again');
      }
    });
  }

  return (
    <form action={handleSubmit}>
      <input name="name" type="text" disabled={isPending} />
      <button type="submit" disabled={isPending}>
        {isPending ? 'Saving...' : 'Save'}
      </button>
      {error && <p className="text-red-500">{error}</p>}
      {success && <p className="text-green-500">Saved!</p>}
    </form>
  );
}

Notice the form's action prop takes the async function directly — that's new in React 19 for HTML forms. No preventDefault dance required. And isPending from useTransition is automatically true while your async action runs. Less boilerplate, same control.

useActionState — the hook that replaces half your form code

If you're using Next.js Server Actions (and you probably are if you're building anything with Next.js 14+), useActionState is the missing piece that makes them feel complete. It was called useFormState in the canary releases, which caused some confusion — we definitely spent 20 minutes wondering why our imports were broken after upgrading.

useActionState wraps a server action and gives you back the current state plus a wrapped action to use in your form. It handles the pending state automatically and threads the previous state through the action so you can do things like accumulate errors or update a counter. Here's the pattern we actually use:

// app/actions/update-profile.ts
'use server';

import { z } from 'zod';
import { db } from '@/lib/db';
import { users } from '@/lib/db/schema';
import { eq } from 'drizzle-orm';

const schema = z.object({
  name: z.string().min(2, 'Name must be at least 2 characters'),
  bio: z.string().max(160, 'Bio must be under 160 characters').optional(),
});

type State = {
  errors?: Record<string, string[]>;
  success?: boolean;
};

export async function updateProfile(
  prevState: State,
  formData: FormData
): Promise<State> {
  const result = schema.safeParse({
    name: formData.get('name'),
    bio: formData.get('bio'),
  });

  if (!result.success) {
    return { errors: result.error.flatten().fieldErrors };
  }

  await db
    .update(users)
    .set(result.data)
    .where(eq(users.id, formData.get('userId') as string));

  return { success: true };
}
// app/components/profile-form.tsx
'use client';

import { useActionState } from 'react';
import { updateProfile } from '@/app/actions/update-profile';

export function ProfileForm({ userId }: { userId: string }) {
  const [state, action, isPending] = useActionState(updateProfile, {});

  return (
    <form action={action}>
      <input type="hidden" name="userId" value={userId} />

      <div>
        <input name="name" type="text" />
        {state.errors?.name && (
          <p className="text-red-500 text-sm">{state.errors.name[0]}</p>
        )}
      </div>

      <div>
        <textarea name="bio" />
        {state.errors?.bio && (
          <p className="text-red-500 text-sm">{state.errors.bio[0]}</p>
        )}
      </div>

      <button type="submit" disabled={isPending}>
        {isPending ? 'Saving...' : 'Save profile'}
      </button>

      {state.success && <p>Profile updated!</p>}
    </form>
  );
}

The third value returned from useActionState — isPending — is new in React 19. Before that you had to combine useFormState with useFormStatus in a child component, which was awkward enough that we avoided it half the time. Now it's one hook, clean API, done.

useOptimistic — actually works, with caveats

useOptimistic is genuinely useful for list UIs where you want instant feedback. The mental model is: give it your current state and a reducer-style function, get back an optimistic value and an updater. While the server is thinking, the UI shows the optimistic version. If the server fails, it snaps back. Simple enough.

import { useOptimistic, useTransition } from 'react';

type Comment = { id: string; text: string; pending?: boolean };

function CommentList({
  comments,
  postId,
}: {
  comments: Comment[];
  postId: string;
}) {
  const [isPending, startTransition] = useTransition();
  const [optimisticComments, addOptimisticComment] = useOptimistic(
    comments,
    (state: Comment[], newComment: Comment) => [...state, newComment]
  );

  async function handleAddComment(formData: FormData) {
    const text = formData.get('comment') as string;
    const tempId = crypto.randomUUID();

    startTransition(async () => {
      addOptimisticComment({ id: tempId, text, pending: true });

      await fetch(`/api/posts/${postId}/comments`, {
        method: 'POST',
        body: JSON.stringify({ text }),
        headers: { 'Content-Type': 'application/json' },
      });
    });
  }

  return (
    <div>
      {optimisticComments.map((comment) => (
        <div key={comment.id} className={comment.pending ? 'opacity-50' : ''}>
          {comment.text}
        </div>
      ))}
      <form action={handleAddComment}>
        <input name="comment" type="text" />
        <button type="submit">Add comment</button>
      </form>
    </div>
  );
}

The caveat: useOptimistic only works inside a transition. Call it outside one and nothing visually happens. We caught this because our first attempt at using it with a regular async function produced no optimistic update, and we spent an embarrassing amount of time checking if we'd imported from the right package. We had. We just forgot the startTransition wrapper.

useOptimistic must be called inside startTransition to actually display the optimistic state. Without it, the update is silently ignored. This isn't obvious from the docs.

The ref changes — no more forwardRef boilerplate

This one's quieter but we use it every week. In React 19, function components can accept ref as a regular prop. No forwardRef wrapper, no display name shenanigans, no explaining to junior devs why we need this HOC just to pass a ref. You get a ref, you pass it to the DOM element, you're done.

// React 18 — the old way
import { forwardRef } from 'react';

const Input = forwardRef<HTMLInputElement, { label: string }>(
  ({ label }, ref) => (
    <div>
      <label>{label}</label>
      <input ref={ref} />
    </div>
  )
);
Input.displayName = 'Input';

// React 19 — just... pass it
function Input({ label, ref }: { label: string; ref?: React.Ref<HTMLInputElement> }) {
  return (
    <div>
      <label>{label}</label>
      <input ref={ref} />
    </div>
  );
}

If you have a design system with 40 input components all wrapped in forwardRef, this upgrade alone is worth a lazy afternoon migration. We went through our shared component library doing exactly this and the diff was satisfying in a way that deleting code always is.

use() — interesting but wait on this one

The new use() hook lets you read the value of a Promise or Context inside render. The context use case is nice — you can call use(MyContext) conditionally, unlike useContext. The Promise case is where things get more experimental-feeling in practice.

The pitch is that you can pass a Promise from a Server Component down to a Client Component, and the Client Component unwraps it with use() while Suspense handles the loading state. In theory, this gives you more control over when things load. In practice, we've found it creates more cognitive overhead than it saves for most features. You have to think carefully about where Suspense boundaries are, what happens when the Promise rejects, and whether your client component is doing too much work.

It's not bad. We use it occasionally for context. For Promises, we're still mostly fetching in Server Components and passing data down as props, which keeps things simpler and easier to reason about during code review. use() for Promises will probably become more natural as patterns mature — it's just not load-bearing for us right now.

What actually changed in the hydration error experience

This doesn't get enough credit. React 19 completely rewrote hydration error messages. Before, you'd get a cryptic diff of what the server rendered versus what the client expected, and you'd be staring at it trying to figure out which of your 200 components was the culprit. Now you get the actual element that mismatched, the specific prop that differs, and often a pointer to the component tree where it happened.

We can't tell you how many hours we've lost to hydration errors over the years. Things like a date rendered differently server-side vs client-side, or a third-party script injecting something into the DOM, or a browser extension messing with the HTML. The new error output makes these debuggable in minutes instead of hours. Not a feature you'll put in a demo, but one you'll appreciate deeply the next time you deploy at 11pm and something's broken.

What to actually upgrade if you're starting fresh

If you're spinning up a new Next.js project today, just use React 19. The ecosystem has mostly caught up, the breaking changes are manageable, and you get all of this out of the box. The patterns that matter most for a typical SaaS or content app are:

  • useActionState for all Server Action forms — replaces useFormState + useFormStatus entirely
  • Drop forwardRef everywhere in your component library — it's dead weight now
  • useOptimistic for list mutations where instant feedback matters (comments, todos, toggles)
  • use(Context) where you currently have deeply nested context consumers — conditional reading is genuinely useful
  • Let the better hydration errors do the work — don't suppress them, read them

If you're upgrading an existing app, the main watch-outs are: some third-party libraries haven't updated from forwardRef yet and will show deprecation warnings (noisy but harmless for now), and if you were using createRoot from react-dom/client you'll want to check the React 19 migration guide for the minor API changes there.

The templates on peal.dev are already on React 19 with all of these patterns baked in — useActionState for auth forms, useOptimistic where it makes sense, the full Server Action setup with Zod validation. If you want to see this stuff in a real project structure rather than isolated examples, that's a good place to dig in.

React 19's biggest wins aren't the headline features — they're the accumulation of smaller improvements that make everyday code less annoying to write and debug.

React 19 isn't a revolution. It's a very good refinement pass on patterns that were already emerging with concurrent features and Server Components. The actions story is now coherent enough to actually teach someone. The ref cleanup removes a category of boilerplate that nobody ever loved. And the improved errors mean less time spelunking through component trees with console.log breadcrumbs. Ship it.

Newsletter

Liked this post? There's more where it came from.

Dev guides, honest build stories, and the occasional 2am debugging confession — straight to your inbox. No spam, unsubscribe anytime.

Browse templates
Written by humansWeekly dropsSubscriber perks

Join the Discord

Ask questions, share builds, get help from founders