Home/Blog/React 19: What's New and How to Migrate Your Apps

React 19: What's New and How to Migrate Your Apps

By TrackMy Tech Team••Engineering•12 min read
A code editor showing a React project with the React logo

React 19 has been stable since December 2024, and the 19.x line has kept adding to it since then. If your app is still on React 18, the upgrade is now well understood: the ecosystem has caught up, the major libraries support it, and the official codemods handle most of the mechanical work.

This guide covers the features that change how you write everyday React code: Actions and the hooks built around them, the use() API, ref as a regular prop, and built-in document metadata. It also covers the React Compiler, which ships separately from React itself but pairs naturally with an upgrade. The second half is a migration plan, based on the official upgrade guide and on what we look out for when upgrading our own frontend code, including Track My Academy.

Actions: Async Mutations Without the Boilerplate

Most React forms used to follow the same pattern: a loading state, an error state, a try/catch/finally and an onSubmit handler that calls preventDefault(). React 19 calls an async function used in a transition an Action, and it manages the pending state, errors and form reset for you.

useActionState with Forms

// Before: manual state management
function CreateStudentForm() {
  const [loading, setLoading] = useState(false);
  const [error, setError] = useState(null);

  const handleSubmit = async (e) => {
    e.preventDefault();
    setLoading(true);
    setError(null);
    try {
      await api.createStudent(new FormData(e.target));
    } catch (err) {
      setError(err.message);
    } finally {
      setLoading(false);
    }
  };

  return (
    <form onSubmit={handleSubmit}>
      {/* form fields */}
      <button disabled={loading}>{loading ? 'Creating...' : 'Create Student'}</button>
      {error && <p className="error">{error}</p>}
    </form>
  );
}
// React 19: useActionState
import { useActionState } from 'react';

async function createStudentAction(prevState, formData) {
  try {
    const student = await api.createStudent({
      name: formData.get('name'),
      email: formData.get('email'),
    });
    return { error: null, student };
  } catch (err) {
    return { error: err.message };
  }
}

function CreateStudentForm() {
  const [state, submitAction, isPending] = useActionState(
    createStudentAction,
    { error: null }
  );

  return (
    <form action={submitAction}>
      <input name="name" required />
      <input name="email" type="email" required />
      <button disabled={isPending}>{isPending ? 'Creating...' : 'Create Student'}</button>
      {state.error && <p className="error">{state.error}</p>}
    </form>
  );
}

A few details worth knowing:

  • The action receives the previous state as its first argument and the FormData as its second. Whatever it returns becomes the new state.
  • Passing a function to <form action> means React handles submission: no preventDefault(), and uncontrolled fields are reset after a successful submit.
  • This works in a plain client-side app. You do not need a server framework to use useActionState; the action can call any API.

useFormStatus for Design-System Buttons

If your submit button lives in a shared component, useFormStatus (from react-dom) lets it read the pending state of its parent form without prop drilling:

import { useFormStatus } from 'react-dom';

function SubmitButton({ children }) {
  const { pending } = useFormStatus();
  return <button type="submit" disabled={pending}>{children}</button>;
}

It must be rendered inside a <form>; it does not read the state of a form rendered in the same component.

Optimistic Updates with useOptimistic

useOptimistic shows a value immediately while an Action is in progress. Once the Action finishes, React drops the optimistic value and shows whatever the real state is at that point. That has an important consequence: on success, you must update the real state, or the UI will snap back.

import { useOptimistic, startTransition } from 'react';

function AttendanceToggle({ student, onSaved }) {
  const [optimisticStatus, setOptimisticStatus] = useOptimistic(student.status);

  function mark(status) {
    startTransition(async () => {
      setOptimisticStatus(status);                 // shown immediately
      const saved = await updateAttendance(student.id, status);
      startTransition(() => onSaved(saved));       // update the real state
    });
  }

  return (
    <div className="attendance-status">
      <span className={optimisticStatus}>{optimisticStatus}</span>
      <button onClick={() => mark('present')}>Present</button>
      <button onClick={() => mark('absent')}>Absent</button>
    </div>
  );
}

The optimistic setter must be called inside an Action (a transition or a form action); calling it from a plain event handler triggers a warning. If updateAttendance throws, the transition ends without onSaved running, so the status reverts to the last real value. You should still catch the error and show a message, since a silent revert is confusing for users.

The use() API

use() reads the value of a promise or a context. Unlike other hooks, it can be called inside conditions and loops.

Reading Promises with Suspense

import { use, Suspense } from 'react';

function StudentProfile({ studentPromise }) {
  const student = use(studentPromise); // suspends until resolved

  return (
    <div className="profile">
      <h2>{student.name}</h2>
      <p>{student.email}</p>
    </div>
  );
}

function StudentPage({ studentPromise }) {
  return (
    <Suspense fallback={<ProfileSkeleton />}>
      <StudentProfile studentPromise={studentPromise} />
    </Suspense>
  );
}

The catch: the promise must be stable across renders. If you write const p = fetchStudent(id) directly in a client component's body, every render creates a new promise, which suspends again, and you get an endless loop. React warns about this. In practice the promise should come from:

  • a Server Component that passes it down as a prop, or
  • a framework loader or a data library (TanStack Query, SWR, a router loader) that caches it, or
  • your own cache keyed by ID.

For most client-side apps, a data-fetching library remains the simpler choice. use() shines when a framework hands you the promise.

Reading Context Conditionally

import { use } from 'react';
import { ThemeContext } from './ThemeContext';

function Heading({ children, themed }) {
  if (!themed) return <h2>{children}</h2>;
  const theme = use(ThemeContext); // allowed after an early return
  return <h2 style={{ color: theme.primary }}>{children}</h2>;
}

useContext cannot be called after an early return like this; use() can.

ref as a Prop

Function components now receive ref as a normal prop, so forwardRef is no longer needed:

// Before
const TextInput = forwardRef((props, ref) => (
  <input ref={ref} className="text-input" {...props} />
));

// React 19
function TextInput({ ref, ...props }) {
  return <input ref={ref} className="text-input" {...props} />;
}

forwardRef still works, but the React team has said it will be deprecated in a future version, and a codemod is available. Ref callbacks can also now return a cleanup function, which React calls when the element is removed:

<div ref={(node) => {
  const observer = new ResizeObserver(handleResize);
  observer.observe(node);
  return () => observer.disconnect();
}} />

Context as a Provider

You can render a context directly instead of using .Provider:

const ThemeContext = createContext('light');

function App({ children }) {
  return <ThemeContext value="dark">{children}</ThemeContext>;
}

<ThemeContext.Provider> still works for now and is expected to be deprecated later.

Document Metadata and Asset Loading

React 19 lets components render <title>, <meta> and <link> tags anywhere in the tree, and hoists them into <head>:

function BlogPost({ post }) {
  return (
    <article>
      <title>{`${post.title} | TrackMy Tech Blog`}</title>
      <meta name="description" content={post.excerpt} />
      <meta property="og:title" content={post.title} />
      <h1>{post.title}</h1>
      {/* ... */}
    </article>
  );
}

Note the template literal in <title>: passing several children ({post.title} - Blog) renders an array, which React warns about for titles. For simple pages this replaces libraries like react-helmet. Frameworks such as Next.js still have their own metadata APIs, which you should prefer there.

Stylesheets get first-class support too. A <link rel="stylesheet"> with a precedence prop is deduplicated and ordered, and React waits for it to load before revealing the content that depends on it. There are also imperative resource hints in react-dom:

import { preload, preinit, preconnect } from 'react-dom';

function Dashboard() {
  preconnect('https://api.example.com');
  preload('/fonts/inter-var.woff2', { as: 'font', type: 'font/woff2', crossOrigin: 'anonymous' });
  preinit('/styles/charts.css', { as: 'style', precedence: 'default' });

  return <div className="dashboard">{/* ... */}</div>;
}

Server Components and Server Actions

Server Components and Server Functions (often called Server Actions) are stable parts of React 19, but you use them through a framework that supports them, such as the Next.js App Router. A plain Vite single-page app does not get them automatically.

// app/actions/students.js
'use server';

import { db } from '@/lib/db';
import { revalidatePath } from 'next/cache';

export async function createStudent(formData) {
  await db.student.create({
    data: {
      name: formData.get('name'),
      email: formData.get('email'),
    },
  });
  revalidatePath('/students');
}
// app/students/new/page.jsx (Server Component)
import { createStudent } from '@/app/actions/students';

export default function NewStudentPage() {
  return (
    <form action={createStudent}>
      <input name="name" required />
      <input name="email" type="email" required />
      <button type="submit">Create Student</button>
    </form>
  );
}

The function runs on the server, but it is still a public endpoint: validate input and check authorisation inside it, exactly as you would in an API route. If you need to return errors to the form, wrap the server function with useActionState in a client component.

The React Compiler

The React Compiler is a build-time tool that adds memoization automatically. It is a separate package, not part of the react package, and reached a stable 1.0 release in 2025. It works best with React 19, and can also target React 17 and 18 with an extra runtime package.

// With the compiler, this plain code is memoized for you
function ProductList({ products, category, onSelect }) {
  const filtered = products.filter((p) => p.category === category);

  return (
    <ul>
      {filtered.map((product) => (
        <ProductCard
          key={product.id}
          product={product}
          onClick={() => onSelect(product)}
        />
      ))}
    </ul>
  );
}

Without the compiler you would wrap filtered in useMemo, the click handler in useCallback and ProductCard in memo to avoid needless re-renders. The compiler does the equivalent analysis per component and caches values based on their real inputs.

Setup for a Vite app using @vitejs/plugin-react with Babel:

npm install --save-dev --save-exact babel-plugin-react-compiler@latest
// vite.config.js
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';

export default defineConfig({
  plugins: [
    react({
      babel: {
        plugins: ['babel-plugin-react-compiler'],
      },
    }),
  ],
});

Next.js has a reactCompiler config option, and other toolchains have their own integration. How the Babel step is wired into Vite has changed between plugin major versions, so check the current React Compiler installation docs for your exact setup.

Practical advice for adopting it:

  • Enable the ESLint rules first. The compiler relies on the Rules of React (pure render, no mutation of props or state). The React Hooks ESLint plugin includes compiler-related rules that flag code it will skip or that may behave differently.
  • Roll out gradually. The compiler can be limited to certain directories, or run in an opt-in mode where only components marked with the "use memo" directive are compiled. "use no memo" opts a component out.
  • Do not rip out existing useMemo/useCallback in bulk. The compiler works alongside them. Remove them over time, with tests, where they no longer add anything.
  • Measure. The gains depend on how much unnecessary re-rendering your app had. Use the React DevTools Profiler before and after rather than assuming a number.

Migration Guide: Upgrading to React 19

Step 1: Upgrade to React 18.3 First

React 18.3 is identical to 18.2 but logs warnings for every API that React 19 removes or changes. Upgrade to it, run the app and the test suite, and fix the warnings before going further.

Step 2: Update Dependencies

npm install react@^19 react-dom@^19
# TypeScript projects
npm install --save-dev @types/react@^19 @types/react-dom@^19

Check that your key libraries (router, state management, UI kit, form library, testing tools) list React 19 in their peer dependencies. Most popular libraries have supported it for a long time, but older or unmaintained packages may not.

Step 3: Run the Codemods

The React team publishes codemods that handle most of the mechanical changes:

npx codemod@latest react/19/migration-recipe
# TypeScript type changes
npx types-react-codemod@latest preset-19 ./src

Step 4: Handle the Breaking Changes

The main removals and changes:

  • propTypes and defaultProps on function components are ignored. Use TypeScript (or another type checker) and ES default parameters instead. Class components keep defaultProps.

    // Before
    function Button({ color, label }) { /* ... */ }
    Button.defaultProps = { color: 'blue', label: 'Click' };
    
    // After
    function Button({ color = 'blue', label = 'Click' }) { /* ... */ }
    
  • ReactDOM.render and ReactDOM.hydrate are removed. Use createRoot and hydrateRoot from react-dom/client.

  • unmountComponentAtNode and findDOMNode are removed. Use root.unmount() and refs respectively.

  • String refs and legacy context (contextTypes, getChildContext) are removed in class components.

  • react-dom/test-utils: act now comes from react; the other utilities are gone.

  • Error handling: errors thrown during render are no longer re-thrown. Uncaught errors go to window.reportError and caught ones to console.error. If you report errors to a monitoring service, use the new onUncaughtError and onCaughtError options on createRoot.

  • TypeScript: useRef now requires an argument, ref callbacks cannot implicitly return a value, and the global JSX namespace moves to React.JSX. The types codemod covers most of this.

Step 5: Update Your Test Setup

  • Use renderHook from @testing-library/react. The separate @testing-library/react-hooks package is deprecated and does not support React 19.
  • react-test-renderer is deprecated; migrate snapshot-style tests to React Testing Library.
  • Upgrade Testing Library and Jest or Vitest to current versions.
npm install --save-dev @testing-library/react@latest @testing-library/dom@latest
npm uninstall @testing-library/react-hooks

Step 6: Keep Strict Mode On and Test

Keep <StrictMode> enabled in development. It double-invokes render and effects to expose impure code, which matters even more if you plan to adopt the compiler. Then run your full test suite, click through critical paths, and watch the console for warnings.

Step 7: Adopt New Features Gradually

There is no need to rewrite working code. A sensible order:

  1. Use Actions and useActionState for new forms and when touching old ones.
  2. Replace forwardRef as you edit components (or run the codemod).
  3. Add useOptimistic where latency is noticeable, such as toggles and lists.
  4. Trial the React Compiler on one area of the app, measure, then widen it.

What Came After 19.0

The 19.x releases have kept adding features. React 19.2, for example, introduced the <Activity> component for keeping hidden parts of the UI mounted with their state preserved, and useEffectEvent for reading the latest props and state inside effects without adding them as dependencies. Check the React blog and changelog for the current release before planning, as newer minor versions may change the best approach for some patterns.

Key Takeaways

  • Actions, useActionState, useFormStatus and useOptimistic remove most hand-written loading and error state from forms and mutations.
  • use() reads promises and context, but promises must be cached or come from a framework.
  • ref as a prop and <Context> as a provider remove boilerplate.
  • Document metadata and resource-loading APIs cover common needs that used to require extra libraries.
  • The React Compiler is a separate, stable tool that automates memoization. Adopt it gradually, with the ESLint rules on.
  • Migrate via React 18.3, run the codemods, fix the removed APIs, update your test tooling, and adopt new features as you touch code.

For full details, the React 19 upgrade guide and the React Compiler documentation are the authoritative references. If you would like help planning an upgrade or building a React application, book a free consultation with TrackMy Tech or read more about our team.

←BACK TO BLOG
STAY UPDATED

MORE FROM THE PRODUCT BLOG

SEE ALL POSTS