Type something to search...
Upgrading to React 19: Breaking Changes and Migration Tips

Upgrading to React 19: Breaking Changes and Migration Tips

React 19 brought Actions, useActionState, useOptimistic, the use hook, ref as a regular prop, and Server Components as a stable feature. It also removed a batch of APIs that had been deprecated for years, changed how errors are reported, and tightened a number of TypeScript types. For a small, modern app the upgrade can take ten minutes. For an older codebase with class components, legacy patterns, and a long dependency list, it can be a multi-day project if you go in blind.

Most of the pain comes from three places: code that still uses removed APIs like ReactDOM.render or string refs, TypeScript errors from the updated type definitions, and third-party libraries that haven't caught up. All three are predictable and mostly fixable with codemods.

This guide walks through the upgrade in order: preparing on React 18.3, installing React 19, running the codemods, fixing each category of breaking change by hand where needed, updating tests, and then adopting the new features once everything is green.

Step 1: Upgrade to React 18.3 First

React 18.3 is identical to 18.2 except that it adds deprecation warnings for every API that React 19 removes. That makes it the perfect staging step:

npm install react@18.3 react-dom@18.3

Run your app and your test suite, and read the console. Each warning points at something that will break or silently stop working in 19. Fixing them here, while everything still runs, is much easier than debugging a broken build later.

While you're on 18.3, check two prerequisites:

  • You must use the new JSX transform. React 19 requires it (it enables ref as a prop and other improvements). If you've upgraded your build tooling in the last few years, you already have it. In TypeScript, this means "jsx": "react-jsx" rather than "jsx": "react". If you see a warning about an outdated JSX transform, fix that first.
  • Check your dependencies. Look at the peer dependency ranges of your UI libraries, routers, form libraries, and testing tools. Most popular packages support React 19 now, but older versions of some libraries pin react@^18.

Step 2: Install React 19

Upgrade React, React DOM, and their types together:

npm install --save-exact react@^19.0.0 react-dom@^19.0.0
npm install --save-exact -D @types/react@^19.0.0 @types/react-dom@^19.0.0

If npm reports peer dependency conflicts from libraries that still declare React 18, check whether a newer version of that library exists before forcing anything. Using --legacy-peer-deps hides the conflict but doesn't make an incompatible library work.

Step 3: Run the Codemods

The React team and community maintain codemods that handle most mechanical changes. Run them on a clean git branch so you can review the diff:

npx codemod@latest react/19/migration-recipe

The recipe bundles several transforms, including replacing ReactDOM.render with createRoot, converting string refs to callback refs, moving the act import from react-dom/test-utils to react, renaming useFormState to useActionState, and converting propTypes to TypeScript types.

For TypeScript projects, run the types codemod as well:

npx types-react-codemod@latest preset-19 ./src

It fixes most of the type-level changes described later in this post. Review both diffs carefully. Codemods are good at patterns and bad at context, so check anything that looks odd.

Removed APIs and How to Replace Them

Here's what React 19 removed, with the replacement for each.

ReactDOM.render and hydrate

The legacy root APIs are gone. Use the root APIs introduced in React 18:

// Before (React 17 style)
import ReactDOM from "react-dom";
ReactDOM.render(<App />, document.getElementById("root"));

// After
import { createRoot } from "react-dom/client";
const root = createRoot(document.getElementById("root")!);
root.render(<App />);

For server-rendered apps, ReactDOM.hydrate becomes hydrateRoot(container, <App />), also from react-dom/client. And unmountComponentAtNode(container) becomes root.unmount(), which means you need to keep a reference to the root you created.

findDOMNode

ReactDOM.findDOMNode was removed. Replace it with a ref on the element you need:

// Before
class Tooltip extends React.Component {
  componentDidMount() {
    const node = ReactDOM.findDOMNode(this) as HTMLElement;
    positionTooltip(node);
  }
  render() {
    return <div className="tooltip">{this.props.children}</div>;
  }
}

// After
function Tooltip({ children }: { children: React.ReactNode }) {
  const ref = useRef<HTMLDivElement>(null);

  useLayoutEffect(() => {
    if (ref.current) positionTooltip(ref.current);
  }, []);

  return (
    <div ref={ref} className="tooltip">
      {children}
    </div>
  );
}

Watch for this one in older libraries too. Some animation and drag-and-drop packages relied on findDOMNode internally, and only their newer major versions work with React 19.

propTypes and defaultProps on function components

propTypes checks were removed entirely and are now silently ignored. If you need runtime validation, use TypeScript for compile-time checks or a schema library like Zod at your data boundaries.

defaultProps no longer works on function components. Use default parameter values:

// Before
function Button({ size, variant, children }: ButtonProps) {
  return <button className={`btn-${size} btn-${variant}`}>{children}</button>;
}
Button.defaultProps = { size: "md", variant: "primary" };

// After
function Button({ size = "md", variant = "primary", children }: ButtonProps) {
  return <button className={`btn-${size} btn-${variant}`}>{children}</button>;
}

This is the change most likely to cause silent bugs, because nothing throws. Props that used to get defaults are suddenly undefined. Search your codebase for defaultProps before upgrading. Class components still support defaultProps.

String refs

String refs like ref="input" and this.refs.input were removed. Use createRef in class components:

class SearchBox extends React.Component {
  inputRef = React.createRef<HTMLInputElement>();

  componentDidMount() {
    this.inputRef.current?.focus();
  }

  render() {
    return <input ref={this.inputRef} />;
  }
}

The codemod converts most string refs to callback refs automatically.

Legacy context

The old contextTypes and getChildContext API was removed. Migrate to createContext, which also works with class components through static contextType. If this is buried in an old dependency, upgrading that dependency is usually the only fix.

Other removals

  • React.createFactory: use JSX or createElement directly.
  • Module pattern factories (function components that return an object with a render method): convert to a regular function or class component.
  • react-dom/test-utils: act moved to react. The other utilities were removed. Use Testing Library instead.
  • react-test-renderer/shallow: removed. Shallow rendering tests should be rewritten with Testing Library.
  • UMD builds: the umd folder is gone. For script-tag usage without a bundler, load React from an ESM CDN.

Changes in Error Handling

React 19 changed how errors thrown during rendering are reported. Previously, React would re-throw errors and you'd often see each one logged two or three times. Now:

  • Uncaught errors (not caught by an error boundary) are reported to window.reportError.
  • Caught errors (caught by an error boundary) are reported to console.error.

If you send errors to a monitoring service, you can hook into both with new root options:

import { createRoot } from "react-dom/client";
import { App } from "./App";
import { reportToMonitoring } from "./monitoring";

const root = createRoot(document.getElementById("root")!, {
  onUncaughtError(error, errorInfo) {
    reportToMonitoring(error, { componentStack: errorInfo.componentStack, handled: false });
  },
  onCaughtError(error, errorInfo) {
    reportToMonitoring(error, { componentStack: errorInfo.componentStack, handled: true });
  },
  onRecoverableError(error) {
    console.warn("Recovered from error", error);
  },
});

root.render(<App />);

If your test suite asserted on how many times an error was logged, or relied on errors propagating out of render in a particular way, those tests may need adjusting. Error boundaries themselves work the same, and the post on error boundaries in React still applies.

TypeScript Changes

The @types/react 19 release removed several long-deprecated types and tightened others. These cause most of the red squiggles after upgrading.

useRef requires an argument

useRef() with no argument is now a type error. Pass an initial value, usually null or undefined:

// Error in React 19 types
const timer = useRef<number>();

// Fixed
const timer = useRef<number | undefined>(undefined);
const inputRef = useRef<HTMLInputElement>(null);

All refs returned by useRef are now mutable RefObject values, so you no longer need the MutableRefObject versus RefObject distinction. useRef<HTMLInputElement>(null) gives you a RefObject<HTMLInputElement | null>.

Ref callbacks can't implicitly return a value

React 19 lets ref callbacks return a cleanup function. Because of that, TypeScript now rejects ref callbacks that return anything else, which catches a common shorthand:

// Error: the arrow function returns the assignment's value
<div ref={(node) => (instance = node)} />

// Fixed: use a block body
<div
  ref={(node) => {
    instance = node;
  }}
/>

The types codemod rewrites these for you.

The global JSX namespace is gone

If you wrote types like JSX.Element or augmented JSX.IntrinsicElements globally, switch to the scoped namespace:

// Before
function render(): JSX.Element { /* ... */ }

// After
import type { JSX } from "react";
function render(): JSX.Element { /* ... */ }

// Or reference it through React
function renderAlt(): React.JSX.Element { /* ... */ }

Other type changes

  • ReactElement props now default to unknown instead of any. Code that reads element.props.something without a type argument needs one, like ReactElement<{ something: string }>.
  • Several deprecated types were removed, including ReactChild, ReactFragment, VoidFunctionComponent, and StatelessComponent. Use ReactNode, FunctionComponent, or plain function types instead.
  • useReducer typings changed so the reducer type is inferred. If you passed the reducer type as an explicit generic, remove it and let TypeScript infer from the reducer function.

The post on TypeScript with React covers the current typing patterns in more detail.

Behavior Changes to Watch For

A few changes don't break the build but can change behavior.

Strict Mode and memoization. During the development-only double render, useMemo and useCallback now reuse the result from the first render instead of calling your function twice. If you were (accidentally) relying on that double call to surface side effects, you'll see it less.

Suspense and siblings. When a component suspends, React 19 commits the nearest fallback immediately instead of first rendering the suspended component's siblings. It then "pre-warms" those siblings in the background. Fallbacks show sooner, but if your siblings kicked off data fetching during render, check that they still start early enough. Starting requests in loaders or event handlers, rather than in render, avoids the issue.

element.ref is deprecated. Since ref is now a regular prop, reading element.ref logs a warning. Read element.props.ref instead. This mostly affects libraries that clone elements.

useFormState was renamed. The react-dom hook useFormState is deprecated in favor of useActionState from react, which also returns a pending flag. See useActionState and form actions for the new API.

Better hydration errors. Hydration mismatches now produce a single error with a diff of what differed, instead of several vague ones. React also tolerates unexpected tags inserted into head and body by browser extensions and third-party scripts.

Updating Your Tests

Testing setups need a few adjustments:

npm install -D @testing-library/react@latest @testing-library/dom@latest
  • React Testing Library 16 lists @testing-library/dom as a peer dependency, so install it explicitly.
  • Import act from react. Imports from react-dom/test-utils will fail.
// Before
import { act } from "react-dom/test-utils";

// After
import { act } from "react";
  • react-test-renderer is deprecated. It still works in 19 but logs a warning. Plan to move snapshot and renderer tests to Testing Library.
  • Shallow rendering is gone. Tests built on react-test-renderer/shallow or Enzyme need rewriting. Enzyme has no React 18 or 19 adapter, so those tests had to move eventually anyway.

A Migration Checklist

Here's the whole process as a list you can work through:

  1. Upgrade to React 18.3 and fix every deprecation warning.
  2. Confirm the new JSX transform is in use.
  3. Check third-party libraries for React 19 support and upgrade them.
  4. Install React 19 and the matching types.
  5. Run react/19/migration-recipe and types-react-codemod preset-19, then review the diffs.
  6. Search for defaultProps on function components and replace them with default parameters.
  7. Fix remaining TypeScript errors.
  8. Update error monitoring to use onUncaughtError and onCaughtError.
  9. Update testing libraries and fix act imports.
  10. Run the full test suite and click through critical flows manually, especially anything with Suspense, refs, or forms.

For large apps, do this on a branch and deploy it to a staging environment first. Behavior changes like the Suspense timing are easier to spot with real data and real network latency.

After the Upgrade: Adopting New Features

Once you're stable on React 19, you can start simplifying code with the new APIs. None of this is required, so adopt it gradually:

  • Ref as a prop. New components can accept ref directly without forwardRef. Existing forwardRef components keep working.
function TextInput({ ref, ...props }: React.ComponentProps<"input">) {
  return <input ref={ref} className="input" {...props} />;
}
  • Context as a provider. Render <ThemeContext value={theme}> instead of <ThemeContext.Provider value={theme}>.
  • Ref cleanup functions. Return a cleanup from a ref callback to remove listeners or observers when the element unmounts.
  • Actions and form hooks. Replace hand-rolled pending and error state with useActionState, useFormStatus, and useOptimistic.
  • Document metadata. Render title and meta tags anywhere in your tree, and React hoists them into the document head.
  • The React Compiler. It's a separate opt-in build step, but React 19 is where it fits best. See the React Compiler explained.

Frequently Asked Questions (FAQ) About Upgrading to React 19

Mostly. Modern function components with hooks usually work unchanged. The breaking changes target APIs that were deprecated for years, such as ReactDOM.render, string refs, legacy context, findDOMNode, and defaultProps on function components. If your app runs on React 18.3 without deprecation warnings, the upgrade is usually smooth.

No. forwardRef still works in React 19. For new components you can accept ref as a regular prop instead, and the React team plans to deprecate forwardRef in a future release, so migrating gradually is a good idea.

React 19 removed defaultProps for function components, and it does so silently. Those props are now undefined unless the parent passes them. Move the defaults into the function's destructured parameters, for example size = "md". Class components still support defaultProps.

First check for a newer major version or an official migration note. If none exists, look for maintained forks or alternatives. Forcing installation with --legacy-peer-deps can work for libraries that simply haven't updated their peer range, but test them carefully, especially if they use removed APIs like findDOMNode.

No. Server Components are an optional feature that requires framework or bundler support. A client-side React 19 app works fine without them, and you can still use Actions, use, useOptimistic, and the other new client features.

For a modern hooks-based app with up-to-date dependencies, often under a day including testing. Older apps with class components, legacy patterns, Enzyme tests, or outdated libraries can take longer, mostly because of dependency upgrades and test rewrites rather than React itself.

Conclusion

Upgrading to React 19 goes smoothly when you take it in order: clear the deprecation warnings on React 18.3, make sure you're on the new JSX transform, upgrade libraries, install React 19 with its types, and let the codemods handle the mechanical changes. Then fix the things codemods can't see, especially defaultProps on function components, TypeScript ref changes, error monitoring hooks, and test utilities.

Once the app is stable, adopt the new features where they simplify real code: ref as a prop for new components, Actions for forms, useOptimistic for instant feedback, and eventually the React Compiler. Upgrading is the hard part. The new APIs are what make it worth doing.

Tags :
Share :

Related Posts

A Practical Guide to useEffect and Its Dependency Array

A Practical Guide to useEffect and Its Dependency Array

useEffect is the hook people get wrong most often, and the dependency array is usually where it goes wrong. Leave a value out and your effect works

Continue Reading
Accessibility Best Practices for React Developers

Accessibility Best Practices for React Developers

React makes it easy to build interfaces out of anything. A div with an onClick looks and behaves like a button for a mouse user, so it ships. The

Continue Reading
Animations in React with Motion (Framer Motion)

Animations in React with Motion (Framer Motion)

CSS transitions get you far, until you need to animate something leaving the page. React removes the element from the DOM immediately, so there's not

Continue Reading