Table of Contents
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++ });
}
Upgrading Related Libraries
When upgrading to React 19, related libraries need to be upgraded as well. Here are the key changes.
| Library | Change |
|---|---|
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 Category | Impact Scope | Files |
|---|---|---|
| Shared components (forwardRef, RefObject) | All apps | ~30 |
| Canvas/Stage hooks (RefObject null) | Canvas-using apps | ~17 |
| useIntersectionObserver generics | Infinite scroll apps | ~11 |
| JSX namespace | Web Components apps | 3 |
| package.json | All apps | 7 |
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
-
Use TypeScript errors as a guide: Installing
@types/react@19first will surface type errors at every location that needs changes. Use this as your checklist. -
Search for
forwardRefto assess scope: Searching forforwardRefacross the entire project reveals all components that need updating at a glance. -
Bulk replace
MutableRefObject: This is a simple substitution, so use IDE’s Find & Replace for quick processing. -
Large yarn.lock changes are normal: In this migration,
yarn.lockalone 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
Deku.Deku created the applications with Flutter.If you have interested, please try to download them for free.