React 20 ref as a Prop: Migrating Away From forwardRef Across a Large Component Library
Eliminate forwardRef in React 20 component libraries by treating ref as a standard prop. Migration patterns, TypeScript updates, versioning strategy, and testing approaches for production codebases.
Most React component library maintenance debt stems from a single historical artifact: forwardRef. The pattern emerged because refs were special-cased in React's original architecture—passing them required wrapping every component that needed to expose a DOM handle. Component library teams spent years adding forwardRef wrappers to hundreds of components, maintaining parallel prop interfaces, and explaining to developers why some components accepted refs while others did not.
React 20 eliminates this complexity by treating ref as a standard prop. The wrapper disappears. The special case vanishes. Teams maintaining component libraries face a straightforward migration path, but the execution requires deliberate planning across versioning, TypeScript definitions, and test coverage.
flowchart LR
A("Component receives ref prop") --> B("forwardRef wrapper intercepts")
B --> C("Extra function layer")
C --> D("TypeScript generics proliferate")
D --> E("Maintenance burden increases")
style E stroke:#fbbf24,fill:#3a2f0b,color:#fef3c7
React 20's approach removes the wrapper entirely. The ref prop flows through component props like className or onClick. TypeScript inference improves because the prop interface becomes a single object instead of a split between props and ref parameters.
flowchart LR
A("Component receives ref prop") --> B("Direct prop destructuring")
B --> C("Single interface definition")
C --> D("Maintenance overhead drops")
style D stroke:#34d399,fill:#0b3b2e,color:#d1fae5
This distinction is critical. Component libraries shipping to thousands of projects must execute this migration without breaking consuming applications. The path forward balances backward compatibility, versioning hygiene, and TypeScript correctness.
Key Takeaways
- React 20 treats
refas a standard prop, eliminating the need forforwardRefwrappers in all component definitions. - Migration requires updating TypeScript interfaces to include
refas a prop field and removingforwardReffunction wrappers from component exports. - Component libraries must treat this as a breaking change, releasing a new major version with clear upgrade documentation and codemods when possible.
- Testing must verify that consuming code can still attach refs to migrated components without runtime errors or TypeScript compilation failures.
- The migration reduces maintenance burden by eliminating parallel prop interfaces and simplifying component signatures across large codebases.
Why React 20 Made ref a Standard Prop
React's original architecture treated refs as a special case because the reconciler needed direct control over DOM element references during commit phases. The forwardRef API emerged as a workaround—a way to thread refs through component boundaries when the props object deliberately excluded them. This created a bifurcation in how developers thought about component APIs: regular props went through the props object, but refs required a separate code path.
The consequence was immediate and pervasive. Every component library that exposed DOM elements to consumers needed forwardRef wrappers. A simple button component became a higher-order function. TypeScript definitions split into two parts: the props interface and the ref type parameter. Documentation had to explain why some components accepted refs while others did not, even when both rendered DOM elements.
React 20 resolves this by integrating ref handling directly into the reconciler's props diffing algorithm. When the reconciler processes a component's props, it now handles ref assignments the same way it handles event handlers or style objects. The special case disappears from the API surface.
flowchart TD
A("React 20 reconciler processes component") --> B("Props diffing algorithm runs")
B --> C("ref handled as standard prop")
C --> D("DOM element receives ref callback")
D --> E("Component renders without wrapper")
style C stroke:#c084fc,fill:#3b0764,color:#f3e8ff,stroke-width:4px
style E stroke:#34d399,fill:#0b3b2e,color:#d1fae5
The implication here is that component library authors no longer maintain two parallel APIs for the same component. A button that accepts onClick can accept ref through the same props object. TypeScript inference works uniformly across all props. The cognitive overhead of explaining ref forwarding to new team members vanishes.
This matters because component libraries often contain hundreds of components. Each forwardRef wrapper represents a maintenance point—a place where TypeScript generics might drift, where documentation must stay synchronized, where automated refactoring tools struggle. Eliminating these wrappers reduces the surface area for bugs and simplifies onboarding for contributors.
Migration Strategy: Identifying Components That Need Updates
The first step in any large-scale migration is establishing which components require changes. Not every component in a library uses forwardRef, and not every component that renders a DOM element needs to expose a ref. The migration targets components where external consumers expect to attach refs—typically leaf components that wrap native HTML elements or third-party DOM-producing libraries.
Start by scanning the codebase for forwardRef imports. A simple grep or AST-based search identifies these components immediately. Cross-reference this list against the library's public API documentation. Any component documented as "ref-capable" must be updated, even if the current implementation does not use forwardRef.
flowchart LR
A("Scan codebase for forwardRef imports") --> B("Build component candidate list")
B --> C("Cross-reference public API docs")
C --> D("Filter to ref-capable components")
D --> E("Prioritize by consumer usage metrics")
style E stroke:#7c9cf0,fill:#142544,color:#eaf2ff
The second filter is usage data. If the library has telemetry or download statistics, prioritize components that appear in the most consuming projects. A button component with 50,000 weekly downloads demands migration before an obscure utility component with 200. This prioritization lets teams ship incremental releases, spreading the migration risk across multiple versions.
Components that render other components from the same library typically do not need changes. If a Card component renders a Button, and both are in the same library, the Card does not need to forward refs—the consuming application attaches refs directly to the Button. This reduces the migration scope significantly in libraries with deep component hierarchies.
The edge cases appear in higher-order components and render prop patterns. A HOC that wraps an arbitrary component must decide whether to expose the wrapped component's ref. In React 19, this required forwardRef at the HOC level. In React 20, the HOC accepts ref as a prop and passes it through manually. The pattern changes, but the core logic remains.
Code Migration Patterns: Before and After Examples
The mechanical transformation from forwardRef to a standard prop follows a consistent pattern. Here is a typical button component in React 19:
import { forwardRef, ButtonHTMLAttributes } from 'react';
interface ButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> {
variant: 'primary' | 'secondary';
}
const Button = forwardRef<HTMLButtonElement, ButtonProps>(
({ variant, children, ...props }, ref) => {
const className = variant === 'primary' ? 'btn-primary' : 'btn-secondary';
return (
<button ref={ref} className={className} {...props}>
{children}
</button>
);
}
);
Button.displayName = 'Button';
export default Button;The React 20 version eliminates the wrapper function and accepts ref as a standard prop:
import { Ref, ButtonHTMLAttributes } from 'react';
interface ButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> {
variant: 'primary' | 'secondary';
ref?: Ref<HTMLButtonElement>;
}
function Button({ variant, children, ref, ...props }: ButtonProps) {
const className = variant === 'primary' ? 'btn-primary' : 'btn-secondary';
return (
<button ref={ref} className={className} {...props}>
{children}
</button>
);
}
export default Button;The changes are minimal but load-bearing. The forwardRef wrapper disappears. The ref prop moves into the ButtonProps interface as an optional field. The function signature becomes a standard function component instead of a callback within forwardRef. The displayName assignment becomes unnecessary because the function name provides it directly.
This pattern scales across component complexity. A more complex component with multiple refs requires explicit prop names, but the structure remains identical. Consider a split-pane component that exposes refs to both panes:
import { Ref } from 'react';
interface SplitPaneProps {
leftRef?: Ref<HTMLDivElement>;
rightRef?: Ref<HTMLDivElement>;
leftContent: React.ReactNode;
rightContent: React.ReactNode;
}
function SplitPane({ leftRef, rightRef, leftContent, rightContent }: SplitPaneProps) {
return (
<div className="split-container">
<div ref={leftRef} className="split-left">
{leftContent}
</div>
<div ref={rightRef} className="split-right">
{rightContent}
</div>
</div>
);
}
export default SplitPane;No forwardRef wrapper appears. The refs flow through props like any other value. The consuming code remains unchanged—developers still pass leftRef={myRef} when rendering the component.
The failure mode here is subtle but expensive. Teams that forget to add ref to the TypeScript interface will ship components that accept refs at runtime but fail TypeScript compilation in consuming projects. The migration must include interface updates alongside function signature changes, and automated tests must verify both paths.
Handling TypeScript: Props Interfaces and Generic Components
TypeScript definitions require deliberate updates during migration. The forwardRef API used a second type parameter for the ref type, separated from the props interface. React 20 collapses this into a single interface, but the type definitions must match the runtime behavior exactly.
Start by importing the Ref type from React. This type represents all valid ref values: callback refs, object refs from useRef, and null. Add it to the props interface as an optional field:
import { Ref } from 'react';
interface InputProps {
label: string;
placeholder?: string;
ref?: Ref<HTMLInputElement>;
}The optional marker is critical. Most consuming code does not attach refs to every component instance. Making ref required would break existing usage patterns and force consumers to pass ref={null} explicitly—a poor developer experience.
Generic components introduce additional complexity. A List<T> component that renders items of type T might need a ref to the container element. The generic type parameter must not conflict with the ref type:
import { Ref } from 'react';
interface ListProps<T> {
items: T[];
renderItem: (item: T) => React.ReactNode;
ref?: Ref<HTMLUListElement>;
}
function List<T>({ items, renderItem, ref }: ListProps<T>) {
return (
<ul ref={ref}>
{items.map((item, index) => (
<li key={index}>{renderItem(item)}</li>
))}
</ul>
);
}The T parameter applies to the items array, while Ref<HTMLUListElement> applies to the container. TypeScript infers both independently. This pattern works because React 20 does not require special handling for refs in generic components—they are just props.
Higher-order components that wrap arbitrary components face a different challenge. The HOC must preserve the wrapped component's ref type while adding its own props. This requires conditional types:
import { ComponentType, Ref, ComponentPropsWithoutRef } from 'react';
function withLogger<P extends object>(
Component: ComponentType<P>
): ComponentType<P & { ref?: Ref<any> }> {
return function LoggedComponent(props: P & { ref?: Ref<any> }) {
console.log('Rendering with props:', props);
return <Component {...props} />;
};
}This approach is fragile and error-prone in large codebases. The better pattern is to avoid ref forwarding in HOCs entirely. If consumers need a ref to the wrapped component, they should render it directly instead of wrapping it in a HOC. This aligns with React's composition philosophy and reduces maintenance burden.
The distinction here matters for libraries shipping to diverse TypeScript configurations. Some consuming projects enable strict null checks; others do not. The ref type must work correctly in both modes, which means using Ref<T> instead of custom union types or optional chaining assumptions.
Breaking Changes and Versioning Strategy for Component Libraries
Migrating from forwardRef to ref-as-prop represents a breaking change for any component library. The runtime behavior remains compatible—components still accept refs—but the TypeScript definitions change shape. Consuming projects that import component types directly will see compilation errors until they update.
The versioning strategy must follow semantic versioning strictly. Increment the major version number when shipping the migration. Document the breaking changes in the changelog with specific examples of old and new usage patterns. Provide a migration guide that shows the diff for common component types.
flowchart LR
A("Identify breaking changes") --> B("Increment major version")
B --> C("Document migration patterns")
C --> D("Publish changelog with diffs")
D --> E("Release with deprecation warnings")
style E stroke:#34d399,fill:#0b3b2e,color:#d1fae5
Some teams attempt to maintain backward compatibility by shipping both forwardRef and ref-as-prop versions in parallel. This strategy creates a maintenance nightmare. The codebase doubles in size for the duration of the compatibility window. Test coverage must verify both code paths. Documentation must explain when to use each variant.
A cleaner approach is a hard cutover with a deprecation period. Ship the new major version with ref-as-prop exclusively. Mark the old version as deprecated in the package registry. Provide a compatibility shim for teams that cannot upgrade immediately:
// compatibility-shim.ts
import { forwardRef, ComponentType } from 'react';
export function createForwardRefShim<P extends object>(
Component: ComponentType<P>
) {
return forwardRef<any, Omit<P, 'ref'>>((props, ref) => {
return <Component {...(props as P)} ref={ref} />;
});
}This shim wraps the new ref-as-prop component in a forwardRef wrapper, providing the old API surface for consumers who have not migrated yet. The shim ships as a separate export, not as the default behavior, so teams opt into compatibility explicitly.
The failure mode here is releasing the migration without adequate communication. Developers upgrading to the new major version encounter TypeScript errors with no clear explanation. The changelog must include a "Migration Guide" section with before-and-after code examples for every common component type in the library.
Related patterns for handling breaking changes in production React applications appear in React 19 concurrent rendering production patterns and React error boundaries production patterns.
Testing Your Migration: Ensuring Ref Forwarding Still Works
Test coverage for ref forwarding requires verifying both runtime behavior and TypeScript compilation. The runtime tests confirm that refs attach to the correct DOM elements. The type tests ensure that consuming code compiles without errors when passing refs.
Start with a basic runtime test using a testing library like Jest and React Testing Library:
import { render } from '@testing-library/react';
import { useRef, useEffect } from 'react';
import Button from './Button';
test('Button forwards ref to underlying button element', () => {
let capturedRef: HTMLButtonElement | null = null;
function TestComponent() {
const buttonRef = useRef<HTMLButtonElement>(null);
useEffect(() => {
capturedRef = buttonRef.current;
}, []);
return <Button ref={buttonRef} variant="primary">Click</Button>;
}
render(<TestComponent />);
expect(capturedRef).toBeInstanceOf(HTMLButtonElement);
expect(capturedRef?.tagName).toBe('BUTTON');
});This test renders the component, attaches a ref, and verifies that the ref points to the correct DOM element type. The test catches regressions where the ref assignment gets dropped during refactoring.
flowchart LR
A("Render component with ref") --> B("useEffect captures ref value")
B --> C("Assert ref points to correct element")
C --> D("Verify DOM node properties")
D --> E("Test passes")
style C stroke:#c084fc,fill:#3b0764,color:#f3e8ff,stroke-width:4px
style E stroke:#34d399,fill:#0b3b2e,color:#d1fae5
Type-level tests require a different approach. Use TypeScript's expectType utility from libraries like tsd or expect-type:
import { expectType } from 'tsd';
import { useRef } from 'react';
import Button from './Button';
const buttonRef = useRef<HTMLButtonElement>(null);
// Should compile without errors
expectType<JSX.Element>(<Button ref={buttonRef} variant="primary">Click</Button>);
// Should reject invalid ref types
// @ts-expect-error
expectType<JSX.Element>(<Button ref={useRef<HTMLDivElement>(null)} variant="primary">Click</Button>);These type tests run during CI and fail the build if component interfaces drift. The tests verify that ref accepts the correct element type and rejects incompatible types.
For component libraries with hundreds of components, generate ref forwarding tests automatically. Write a script that scans the codebase for exported components, generates a test file for each, and runs the suite during CI. This approach ensures comprehensive coverage without manual test authoring.
The edge case appears in components that conditionally render different elements based on props. A component that renders either a button or an a element depending on an href prop must type the ref union correctly:
import { Ref } from 'react';
interface ButtonLinkProps {
href?: string;
children: React.ReactNode;
ref?: Ref<HTMLButtonElement | HTMLAnchorElement>;
}
function ButtonLink({ href, children, ref }: ButtonLinkProps) {
if (href) {
return <a ref={ref as Ref<HTMLAnchorElement>} href={href}>{children}</a>;
}
return <button ref={ref as Ref<HTMLButtonElement>}>{children}</button>;
}Testing these components requires separate test cases for each rendering path, verifying that the ref attaches to the correct element type in each scenario.
Frequently Asked Questions
What happens to existing code using forwardRef after upgrading to React 20?
Existing forwardRef usage continues to work in React 20—the API remains supported for backward compatibility. However, new code should adopt ref-as-prop to avoid the wrapper overhead and simplify TypeScript definitions.
Can a component library ship both forwardRef and ref-as-prop versions during a transition period?
Yes, but maintaining both versions doubles the maintenance burden and test surface area. A cleaner approach is to ship ref-as-prop exclusively in a new major version and provide a compatibility shim for teams that need the old API temporarily.
How do higher-order components handle refs in React 20?
HOCs accept ref as a standard prop and pass it through to the wrapped component. The HOC's props interface must include ref with the appropriate element type, and the component must forward it explicitly in the JSX.
Do TypeScript generic components require special handling for refs?
No special handling is required. Generic components accept ref as a prop with the appropriate element type, and TypeScript infers both the generic type parameter and the ref type independently without conflicts.
What testing strategy verifies that refs work after migration?
Runtime tests should render the component with a ref, capture the ref value in a useEffect, and assert that it points to the correct DOM element. Type-level tests using expectType should verify that the component accepts valid ref types and rejects invalid ones.
Conclusion: Simplifying Component APIs Post-Migration
The migration from forwardRef to ref-as-prop eliminates a historical artifact that added complexity without delivering proportional value. Component libraries that complete this migration reduce their maintenance surface, improve TypeScript inference, and simplify onboarding for contributors who no longer need to understand why refs require special handling.
The execution requires discipline: semantic versioning, comprehensive testing, clear migration documentation, and a willingness to treat the change as the breaking change it is. Teams that rush the migration without adequate communication will see support requests spike as consumers encounter unexpected TypeScript errors.
That covers the essential patterns for migrating large component libraries to React 20's ref-as-prop system. Apply these in production and the difference will be immediate—fewer wrapper functions, cleaner type definitions, and a codebase that aligns with React's evolving composition model.