Skip to content
Back to Software Dev
Software Dev4 min read

React 19 Was Worth the Wait

reactfrontendjavascript
Share

I built AssessAI on React 19 from day one. Not because I wanted to be on the bleeding edge — because the features solved real problems I was hitting on every previous project.

After shipping 200+ source files on it, here's what mattered and what didn't.

server components are the big deal

The mental model shift is simple: components render on the server by default. They can be async. They can fetch data directly. No useEffect. No loading states for initial data. No client-side waterfall.

// This is a server component. It runs on the server.
// No "use client" directive, no useState, no useEffect.
async function AssessmentList() {
  const assessments = await db.query("SELECT * FROM assessments WHERE active = true");
 
  return (
    <ul>
      {assessments.map((a) => (
        <li key={a.id}>{a.title}</li>
      ))}
    </ul>
  );
}

The HTML ships fully rendered. The JavaScript for this component? Zero bytes. The client never downloads the database query, the data fetching logic, or the rendering code. It just gets HTML.

On AssessAI this meant the assessment dashboard loads with data already in the DOM. No loading spinner. No layout shift. The user sees content immediately.

use() replaces half of useEffect

The use() hook reads promises and contexts. It works during render — not in an effect that fires after paint.

"use client";
import { use } from "react";
 
function QuestionDisplay({ questionPromise }: { questionPromise: Promise<Question> }) {
  const question = use(questionPromise); // suspends until resolved
  return <div>{question.text}</div>;
}

This replaces the pattern of useEffect + useState + loading state + error state that we've been writing for years. The promise is created in a server component and passed down. The client component just reads it. Suspense handles the loading boundary.

The subtle win: the fetch starts on the server when the page renders, not when the client component mounts. You eliminate the client-side waterfall entirely.

actions simplified forms

React 19 Actions handle form submissions as a first-class concept. useActionState gives you pending state, error handling, and progressive enhancement in one hook:

"use client";
import { useActionState } from "react";
 
function SubmitResponse({ questionId }: { questionId: string }) {
  const [state, submitAction, isPending] = useActionState(
    async (_prev: any, formData: FormData) => {
      const response = formData.get("response") as string;
      const result = await saveResponse(questionId, response);
      if (!result.ok) return { error: result.message };
      return { success: true };
    },
    null
  );
 
  return (
    <form action={submitAction}>
      <textarea name="response" />
      <button disabled={isPending}>
        {isPending ? "Saving..." : "Submit"}
      </button>
      {state?.error && <p className="text-red-500">{state.error}</p>}
    </form>
  );
}

No onSubmit. No e.preventDefault(). No manual setLoading(true) / setLoading(false). The form works without JavaScript (progressive enhancement) and upgrades to async when JS loads.

One gotcha that burned me: programmatic input (like from browser automation or testing tools) doesn't trigger React 19's form action handlers the way onChange used to work. This broke my Chrome-based E2E testing. I had to fall back to API-level testing for form flows.

what didn't change much

useOptimistic is nice but niche. I used it exactly once — for optimistic UI updates when candidates save responses. The API is clean but I wouldn't restructure code around it.

The new ref as prop (no more forwardRef) is a nice papercut fix. Not a feature. Just less boilerplate.

useFormStatus is useful inside submit buttons but I rarely need it outside that specific pattern.

the real shift

React 19 isn't a list of features. It's a paradigm change in where code runs. Server by default, client when needed. Data fetches start on the server, not in a useEffect. Forms submit natively before JS even loads.

The migration cost for existing apps is real — the server/client boundary requires rethinking component architecture. But for new projects, the developer experience is genuinely better. Less state management. Less loading boilerplate. Less JavaScript shipped to the browser.

I wouldn't go back.


Share

More in Software Dev