[React] Practical Guide to React 19 Migration in a Large Monorepo

2026-03-03 hit count image

Sharing real-world experience upgrading a large monorepo with 7 apps and shared component libraries to React 19. Covers key changes like forwardRef removal, RefObject type unification, useRef initial value requirement, JSX namespace changes, and migration strategies.

react

Overview

With the official release of React 19, many projects are considering migration. In this post, we share real-world experience upgrading a large monorepo containing 7 apps and shared component libraries to React 19.

Over 70 files were changed, but most changes were type-level responses to @types/react@19 — there were almost no runtime behavior changes.

Key Changes

forwardRef Removed — ref Promoted to a Regular Prop

The most notable change in React 19 is the deprecation of forwardRef. You can now pass ref directly as a regular prop.

Before (React 18)

import { forwardRef, memo } from 'react';

type ActionButtonProps = {
  readonly label: string;
  readonly onClick: () => void;
};

const _ActionButton = forwardRef<HTMLButtonElement, ActionButtonProps>(
  ({ label, onClick }, ref) => {
    return (
      <button ref={ref} onClick={onClick}>
        {label}
      </button>
    );
  }
);

export const ActionButton = memo(_ActionButton);

After (React 19)

import { memo, type Ref } from 'react';

type ActionButtonProps = {
  readonly ref?: Ref<HTMLButtonElement>;
  readonly label: string;
  readonly onClick: () => void;
};

const _ActionButton = ({ ref, label, onClick }: ActionButtonProps) => {
  return (
    <button ref={ref} onClick={onClick}>
      {label}
    </button>
  );
};

export const ActionButton = memo(_ActionButton);

With the forwardRef wrapper gone, the code becomes much cleaner. The pattern of separating _Component and wrapping with forwardRef simplifies into a plain function component + memo.

MutableRefObject → Unified into RefObject

In @types/react@19, MutableRefObject has been deprecated and unified into RefObject. Simply replace it in your type declarations.

Before

import { type MutableRefObject } from 'react';

type TableContextType = {
  readonly fetchError: MutableRefObject<boolean>;
  readonly tableHeaderRefs: readonly MutableRefObject<unknown>[];
};

After

import { type RefObject } from 'react';

type TableContextType = {
  readonly fetchError: RefObject<boolean>;
  readonly tableHeaderRefs: readonly RefObject<unknown>[];
};

useRef Initial Value Required + RefObject Type Change

In React 19, useRef() requires an initial value. Additionally, RefObject<T> has been changed to RefObject<T | null>, requiring explicit inclusion of null in the type.

Before

// Calling without initial value (allowed in React 18)
const previousDepsRef = useRef<DependencyList>();

// RefObject<T> without null
const stageRef: RefObject<Konva.Stage> = useRef(null);

After

// Explicitly pass initial value
const previousDepsRef = useRef<DependencyList | undefined>(undefined);

// RefObject<T | null> to include null
const stageRef: RefObject<Konva.Stage | null> = useRef(null);

This change affected dozens of files, especially in projects with many Canvas/Stage-related hooks.

Adding Generic Types to Custom Hooks

Due to the RefObject type change, custom hooks also needed generic types. Let’s look at the useIntersectionObserver case.

Before

export const useIntersectionObserver = (callback: () => void) => {
  const observerRef = useRef<IntersectionObserver | null>(null);

  const targetRef = useCallback((node: Element | null) => {
    if (observerRef.current) {
      observerRef.current.disconnect();
    }
    if (node) {
      observerRef.current = new IntersectionObserver(([entry]) => {
        if (entry.isIntersecting) callbackRef.current();
      });
      observerRef.current.observe(node);
    }
  }, []);

  return targetRef;
};

After

export const useIntersectionObserver = <T extends Element>(
  callback: () => void
) => {
  const observerRef = useRef<IntersectionObserver | null>(null);

  const targetRef = useCallback((node: T | null) => {
    if (observerRef.current) {
      observerRef.current.disconnect();
    }
    if (node) {
      observerRef.current = new IntersectionObserver(([entry]) => {
        if (entry.isIntersecting) callbackRef.current();
      });
      observerRef.current.observe(node);
    }
  }, []);

  return targetRef;
};

After this change, the consuming side needs to specify the generic type explicitly.

const targetRef = useIntersectionObserver<HTMLDivElement>(loadMore);

Global JSX Namespace Deprecated

In React 19, the global JSX namespace has been deprecated and moved to React.JSX. If you use custom Web Components, you need to change the type declaration approach.

Before

declare global {
  namespace JSX {
    interface IntrinsicElements {
      readonly 'custom-header': unknown;
    }
  }
}

After

declare module 'react' {
  namespace JSX {
    interface IntrinsicElements {
      readonly 'custom-header': unknown;
    }
  }
}

ReactElement.props Changed to unknown

In @types/react@19, the props type of ReactElement changed from any to unknown. When using isValidElement or cloneElement, you need to specify the type argument explicitly.

Before

if (isValidElement(child)) {
  return cloneElement(child, { index: columnIndex++ });
}

After

if (isValidElement<{ index: number }>(child)) {
  return cloneElement(child, { index: columnIndex++ });
}

When upgrading to React 19, related libraries need to be upgraded as well. Here are the key changes.

LibraryChange
react / react-dom^19
@types/react / @types/react-dom^19
@testing-library/react^16.0.0 (React 19 support)
react-konva (if using Canvas)^19
Storybook^8.5.0

Impact Analysis by Project

In monorepo migrations, changes to shared packages affect all apps, so it’s important to assess the impact scope in advance.

Change CategoryImpact ScopeFiles
Shared components (forwardRef, RefObject)All apps~30
Canvas/Stage hooks (RefObject null)Canvas-using apps~17
useIntersectionObserver genericsInfinite scroll apps~11
JSX namespaceWeb Components apps3
package.jsonAll apps7

Migration Strategy

Step 1: Start with Shared Libraries

Fix the shared component library first. Complete foundational work like forwardRef removal, RefObject type unification, and adding generic types to custom hooks.

Step 2: Address Each App Sequentially

After shared library changes, fix type errors in each app sequentially. Most are mechanical type fixes, so you can leverage IDE’s bulk find-and-replace functionality.

Step 3: Tests and Snapshot Updates

Since forwardRef removal can subtly change component rendering results, regenerate snapshot tests.

Migration Tips

  1. Use TypeScript errors as a guide: Installing @types/react@19 first will surface type errors at every location that needs changes. Use this as your checklist.

  2. Search for forwardRef to assess scope: Searching for forwardRef across the entire project reveals all components that need updating at a glance.

  3. Bulk replace MutableRefObject: This is a simple substitution, so use IDE’s Find & Replace for quick processing.

  4. Large yarn.lock changes are normal: In this migration, yarn.lock alone had about 5,000 lines of changes, but this is a natural result of dependency tree restructuring.

Wrapping Up

The key takeaway from React 19 migration is that most changes are at the type level. There are almost no runtime behavior changes, and working through TypeScript compilation errors one by one naturally completes the migration.

The forwardRef deprecation may feel like a big change at first, but it’s actually a positive change that makes code simpler and more intuitive. In monorepo environments, proceeding in the order of shared packages → individual apps allows for systematic handling.

Other new features that React 19 brings (Actions, use() hook, Server Components, etc.) are recommended to be adopted incrementally after migration. First, complete the upgrade stably, then apply new features one by one as needed — that’s the safest approach.

After completing the React 19 upgrade, consider adopting React Compiler. With React Compiler, rendering is automatically optimized without manual memoization.

Was my blog helpful? Please leave a comment at the bottom. it will be a great help to me!

App promotion

You can use the applications that are created by this blog writer Deku.
Deku created the applications with Flutter.

If you have interested, please try to download them for free.



SHARE
Twitter Facebook RSS