Three Months of React 19 in Production: The Honest Version

By Odilon14 min read

Three Months of React 19 in Production: The Honest Version#

TL;DR

  • useActionState replaced roughly 40% of the custom form state management code across both apps -- this number is real, not marketing
  • Bundle size dropped about 4KB after removing forwardRef wrappers and manual memoization hooks. Not dramatic, but free
  • I spent 3 days debugging a hydration mismatch that only appeared in production on specific mobile browsers. That was not fun
  • useOptimistic made MoneyTrack's transaction entry feel instant. Users noticed. Actually got a "did you make the app faster?" message
  • Would I do it again? Yes. Would I do it the same way? No

I wrote the React 19 complete guide (post #29) in December, full of excitement about the new APIs and how clean the code was going to be. This post is the follow-up where I tell you what actually happened when I shipped it.

Two apps. Real users. Three months. Here's the honest version.

The Apps#

EduPlay is a math learning platform for kids aged 6-12. Timed exercise sessions, badges, streaks, curriculum progression. Multi-tenant under /s/eduplay/. The architecture is hexagonal -- domain use cases, application ports, infrastructure adapters -- with server actions as the composition root. The user base is small but the users (kids) are brutal testers. They click things fast, they click things twice, they navigate away mid-submission.

MoneyTrack is a personal finance tracker. Accounts, transactions, budgets, currency exchange rates. Also multi-tenant, also hexagonal. More data-dense -- dashboards, charts, filtering. The users are just me and a few friends, but I use it daily, so bugs surface fast.

Both were on React 18.3 before the migration.

What Got Better#

EduPlay: The Exercise Submission Flow#

This was the poster child for the migration. Before React 19, submitting an exercise answer looked like this:

tsx
// the React 18 version -- four useState calls for one form
function ExercisePlayer({ exercise, sessionId }: ExercisePlayerProps) {
  const [answer, setAnswer] = useState("");
  const [isSubmitting, setIsSubmitting] = useState(false);
  const [feedback, setFeedback] = useState<"correct" | "incorrect" | null>(null);
  const [error, setError] = useState<string | null>(null);

  async function handleSubmit() {
    if (!answer.trim()) return;
    setIsSubmitting(true);
    setError(null);

    try {
      const result = await submitExerciseAnswerAction({ sessionId, exerciseId: exercise.id, answer });
      setFeedback(result.isCorrect ? "correct" : "incorrect");
    } catch {
      setError("Failed to submit. Please try again.");
    } finally {
      setIsSubmitting(false);
    }
  }

  // ... render
}

The React 19 version:

tsx
"use client";

import { useActionState } from "react";
import { submitExerciseAnswerAction } from "@/modules/eduplay/infrastructure/actions/submit-exercise-answer-action";

function ExercisePlayer({ exercise, sessionId }: ExercisePlayerProps) {
  const [state, formAction, isPending] = useActionState(
    submitExerciseAnswerAction,
    { feedback: null, error: null }
  );

  return (
    <form action={formAction} className="space-y-6">
      <input type="hidden" name="sessionId" value={sessionId} />
      <input type="hidden" name="exerciseId" value={exercise.id} />

      <ExercisePrompt exercise={exercise} />

      <AnswerInput name="answer" disabled={isPending || state.feedback !== null} />

      <SubmitButton />

      {state.feedback === "correct" && <CorrectFeedback />}
      {state.feedback === "incorrect" && <IncorrectFeedback correctAnswer={state.correctAnswer} />}
      {state.error && <p className="text-red-600 text-sm">{state.error}</p>}
    </form>
  );
}

Four useState calls became one useActionState. The SubmitButton uses useFormStatus internally -- no prop drilling. The server action is clean:

ts
"use server";

import { getMongoDb } from "@/lib/infrastructure/mongodb";
import { MongoExerciseSessionRepository } from "@/modules/edu-core/infrastructure/persistence/mongo-exercise-session-repository";
import { SubmitExerciseAnswer } from "@/modules/edu-core/application/use-cases/submit-exercise-answer";

type State = { feedback: "correct" | "incorrect" | null; correctAnswer?: string; error: string | null };

export async function submitExerciseAnswerAction(
  _prev: State,
  formData: FormData
): Promise<State> {
  const sessionId = formData.get("sessionId") as string;
  const exerciseId = formData.get("exerciseId") as string;
  const answer = formData.get("answer") as string;

  const db = await getMongoDb();
  const sessionRepo = new MongoExerciseSessionRepository(db);
  const useCase = new SubmitExerciseAnswer(sessionRepo);

  const result = await useCase.execute({ sessionId, exerciseId, answer });

  if (!result.isCorrect) {
    return { feedback: "incorrect", correctAnswer: result.correctAnswer, error: null };
  }

  return { feedback: "correct", error: null };
}

The hexagonal architecture made this almost too easy. The action is a thin composition root. The domain logic didn't change at all. I literally didn't touch a single use case file during the migration.

MoneyTrack: Instant Transaction Entry#

This is where useOptimistic really shined. Users log transactions multiple times a day. The old flow had a ~500ms wait before the transaction appeared in the list. With optimistic updates, it appears instantly:

tsx
"use client";

import { useOptimistic, useActionState } from "react";
import { addTransactionAction } from "@/modules/money-track/infrastructure/actions/add-transaction-action";
import type { Transaction } from "@/modules/money-track/domain";

export function TransactionList({
  initialTransactions,
  accountId,
}: TransactionListProps) {
  const [optimisticTransactions, addOptimistic] = useOptimistic(
    initialTransactions,
    (current, newTx: Transaction) => [newTx, ...current]
  );

  const [state, formAction, isPending] = useActionState(
    async (prev: { error: string | null }, formData: FormData) => {
      const amount = parseFloat(formData.get("amount") as string);
      const description = formData.get("description") as string;
      const type = formData.get("type") as "income" | "expense";

      // show it immediately
      addOptimistic({
        id: `optimistic-${Date.now()}`,
        accountId,
        amount,
        description,
        type,
        date: new Date().toISOString(),
        isPending: true,
      });

      return addTransactionAction(prev, formData);
    },
    { error: null }
  );

  return (
    <div className="space-y-4">
      <TransactionForm formAction={formAction} isPending={isPending} />
      {state.error && (
        <p className="rounded bg-red-50 px-3 py-2 text-sm text-red-700">{state.error}</p>
      )}
      <ul className="divide-y">
        {optimisticTransactions.map((tx) => (
          <TransactionRow key={tx.id} transaction={tx} />
        ))}
      </ul>
    </div>
  );
}

The transaction shows up at the top of the list the moment you submit. The server confirmation comes ~500ms later, but you don't wait for it. A friend who uses the app texted me "did you make MoneyTrack faster?" and I felt unreasonably proud about that.

What Went Wrong#

The Hydration Mismatch From Hell#

Day 12 of the migration. Everything works in development. Tests pass. I deploy to Vercel. Open the site on my phone. Works fine. A friend opens it on his Android phone (Chrome 119 on some Samsung device). The EduPlay exercise page renders, then the whole thing flickers and re-renders with incorrect state.

I spent three days on this. Three damn days at my standing desk, drinking mate, staring at the React DevTools network panel on a remote debugging session.

The root cause: one of my components was rendering a <time> element with new Date().toLocaleDateString(). The server rendered it with en-US locale (Vercel's node runtime). The client rendered it with whatever locale the browser was set to. In development, server and client were the same machine, same locale. In production, they diverged.

This bug existed before React 19. But React 18's hydration mismatch handling was more lenient -- it would silently patch the DOM. React 19 is stricter about hydration mismatches in certain cases, and this one triggered a full client-side re-render that cascaded through the exercise state.

The fix was two lines: use Intl.DateTimeFormat with an explicit locale, or render the date in a client component. I went with the explicit locale. Three days of debugging for two lines of code. Classic.

Third-Party Library Type Wars#

The ref-as-prop change broke TypeScript types for several libraries. My date picker, a virtualized list component, and Recharts all had type errors that compiled fine at runtime but made the TypeScript compiler angry. Error messages like "Type 'Ref<HTMLDivElement>' is not assignable to..." everywhere.

I ended up pinning library versions and adding as any in five files. Not proud of those casts, but the alternative was waiting for maintainers to ship updates. Most did within six weeks. By February everything was clean again.

The lesson I should have learned but keep re-learning: audit your third-party deps before upgrading the framework. Check if they've published React 19 compatible versions. I didn't, and it cost me a full day of whack-a-mole with type errors.

use() and Promise Identity#

I was excited to use use() for streaming data to client components in MoneyTrack's dashboard. The idea: fetch account summaries, recent transactions, and budget status in parallel on the server, pass the Promises to client components, let them suspend independently.

In practice, the Promise identity issue bit me immediately. Any time the parent component re-rendered (even for totally unrelated reasons), it created new Promise objects, and use() suspended again. The dashboard was re-suspending on every form submission anywhere on the page.

The fix -- memoizing the Promises, or caching them in a stable reference -- added enough complexity that I went back to the simpler pattern: fetch data in Server Components, pass resolved data as props. Less clever, more reliable.

I still think use() will be great once the ecosystem builds better caching primitives around it. But for now, for my use cases, the boring approach wins.

Server Action Composition in Deep Trees#

EduPlay's student dashboard has four independent sections: streak widget, recent exercises, badge progress, scheduled exercises. Each section needs its own mutations.

My first instinct was to give each section its own useActionState. But when sections are nested deep in the component tree, threading actions through intermediate components that don't care about them gets ugly. It's the same prop-drilling problem we've always had, just with actions instead of callbacks.

My solution was to keep most sections as pure Server Components with no mutation capability, and lift the mutations to page-level modals triggered by URL state. Not a React 19 limitation -- this is just form architecture. But the new APIs made me re-think how I structure interactive regions on a page.

The Numbers#

I measured what I could:

EduPlay exercise submission latency (button press to feedback visible): median went from 620ms to 480ms. The server round-trip didn't change -- the improvement is from less client-side state management overhead and React 19's transition scheduling.

MoneyTrack transaction entry (submit to visible in list): effectively 0ms now due to optimistic updates. Server confirmation still takes ~500ms, but the user doesn't wait. This is the single biggest UX improvement from the migration.

Bundle size: React 19 itself is slightly smaller than 18.3. Removing forwardRef wrappers and some unnecessary useMemo/useCallback calls saved ~4KB total across both apps. Not transformative, but it's free size reduction.

Lines of form state management code removed: I counted roughly 40% reduction. Four useState calls becoming one useActionState adds up across dozens of forms.

Time spent on migration bugs: approximately 5 days total across both apps, with the hydration mismatch accounting for 3 of those days. Without that bug, it would have been a 2-day migration. Smooth.

What I'd Do Differently#

Run the official codemod first. I did this for EduPlay but not for MoneyTrack. The codemod handles the ref cleanup changes and a few other breaks automatically. I spent time on MoneyTrack manually fixing things the codemod would have caught.

Check third-party library compatibility before upgrading React. Not after. Not during. Before. Make a checklist of every UI library you depend on and verify they've published React 19 compatible types.

Migrate one module at a time. The hexagonal architecture made this natural -- I migrated edu-core's server actions first, verified everything worked, then moved to money-track. If your architecture supports it, don't try to migrate everything in one shot.

Don't rewrite working forms "just because." I did this with two forms in MoneyTrack that were perfectly fine on the old pattern. The rewrite introduced a regression I had to debug. If it works, schedule the refactor for when you're not also dealing with a framework upgrade.

Set up locale-aware rendering from the start. The hydration mismatch that cost me three days would have been caught by a simple rule: never use toLocaleDateString() or similar locale-dependent APIs in Server Components without an explicit locale parameter.

Would I do the migration again? Yes, without question. The code is cleaner, the UX is measurably better, and the new APIs are genuinely well-designed. But I'd budget a week for it instead of the "long weekend" I originally planned. Production surprises are production surprises.

Three Months of React 19 in Production: The Honest Version | Blog