WingoUI
ComponentsTemplatesDocsChangelogBlogAI agents
ThemePricing
  1. Home
  2. Blog
  3. Next.js and Tailwind CSS
  4. React 19 forwardRef Migration: ref as a Prop in TypeScript
  1. Blog
  2. Next.js and Tailwind CSS
  3. React 19 forwardRef Migration: ref as a Prop in TypeScript
Next.js and Tailwind CSS
Next.js and Tailwind CSS

React 19 forwardRef Migration: ref as a Prop in TypeScript

SR
Serban Rusu · Founder of Wingo UI
Oct 9, 2026 · 9 min read

On this page

0%
  1. What changed about forwardRef in React 19?
  2. How do you migrate a component from forwardRef to ref as a prop?
  3. How do you type ref as a prop in React 19 with TypeScript?
    1. Generic components get their type parameter back
    2. Wrapper layers pass the ref without extra code
  4. How do you merge refs in React 19?
  5. How does ref as a prop work with asChild and Slot?
  6. What should you try next?
  7. FAQ
    1. Is forwardRef deprecated in React 19?
    2. How do I type the ref prop in React 19 with TypeScript?
    3. Do I still need forwardRef if my library supports React 18?
    4. Does asChild still work without forwardRef?
    5. Is there a codemod to remove forwardRef?
    6. Do class components get ref as a prop in React 19?

TL;DR

In React 19, function components receive ref as a regular prop, so you delete forwardRef, read ref from props (or let it travel in ...props) and type it with ComponentProps or Ref<HTMLInputElement>. forwardRef still works in React 19.3 and logs no warning, but React plans to deprecate it, and a library that supports React 18 has to keep it. The parts that need care are merged refs, which should pass on cleanup functions, and asChild, where the ref belongs on the child element.

Published Oct 9, 2026

Your components are full of forwardRef wrappers, displayName lines and ComponentPropsWithoutRef types, and React 19 says you no longer need them. The React 19 forwardRef change is small on paper: function components now receive ref as a regular prop. The work is at the edges. Props types that strip the ref, components that need the DOM node themselves, and asChild wrappers that hand the ref to a child all behave a little differently once the wrapper is gone. This tutorial migrates an input and a button, checked against React 19.3 and TypeScript 5.9, and shows the patterns we use in Wingo UI, whose source does not call forwardRef once.

What changed about forwardRef in React 19?

Function components get ref in their props, so the wrapper has nothing left to do. The React 19 release post (opens in a new tab) says new function components "will no longer need forwardRef" and that React will "deprecate and remove forwardRef" in future versions. The forwardRef reference (opens in a new tab) says the same: no longer necessary, deprecated in a future release.

So the React 19 forwardRef replacement is the ref prop itself. Four facts decide how you migrate:

  • forwardRef still works and stays quiet. We searched the development builds of react and react-dom 19.3.0 for a deprecation message and found none. Old components keep running while you migrate.
  • Class components did not change. A ref passed to a class still points to the instance and never shows up in its props.
  • element.ref is on its way out. React 19 still fills it, but reading it in development logs "Accessing element.ref was removed in React 19. ref is now a regular prop." Read element.props.ref instead.
  • React 18 strips ref from props. A library that still lists React 18 as a peer dependency has to keep forwardRef. Radix does: @radix-ui/react-slot 1.3.3 wraps its Slot in React.forwardRef and accepts React 16.8 through 19. Wingo UI requires React 19, so its components take ref as a plain prop.

How do you migrate a component from forwardRef to ref as a prop?

Delete the wrapper, type the props with ComponentProps, and let the ref travel in ...props. Here is the classic shadcn-style input before:

tsx
// components/ui/input.tsx (React 18)
import * as React from "react";
import { cn } from "@/lib/utils";
const Input = React.forwardRef<HTMLInputElement, React.ComponentPropsWithoutRef<"input">>(
({ className, type = "text", ...props }, ref) => (
<input ref={ref} type={type} className={cn("h-10 w-full rounded-[10px] px-3", className)} {...props} />
),
);
Input.displayName = "Input";
export { Input };

And after:

tsx
// components/ui/input.tsx (React 19)
import type { ComponentProps } from "react";
import { cn } from "@/lib/utils";
export function Input({ className, type = "text", ...props }: ComponentProps<"input">) {
return (
<input
data-slot="input"
type={type}
className={cn("h-10 w-full rounded-[10px] px-3", className)}
{...props}
/>
);
}

ComponentProps<"input"> already contains ref?: Ref<HTMLInputElement>, so the spread hands it to the <input> and <Input ref={inputRef} /> works without one ref-specific line. A named function gives React DevTools its name, so displayName goes too.

For a whole folder, start with the official codemod from reactjs/react-codemod (opens in a new tab), on a clean branch so you can read the diff:

bash
npx codemod react/19/remove-forward-ref --target components/ui

shadcn/ui made the same move when it switched to React 19. The shadcn Tailwind v4 guide (opens in a new tab) lists the manual steps: replace React.forwardRef<...> with React.ComponentProps<...>, remove ref={ref}, add a data-slot attribute, convert to a named function and drop displayName. Their current Button types its props as React.ComponentProps<"button"> plus its variants and asChild. If your project still has the older shadcn forwardRef components with local edits, updating shadcn components without losing edits covers the merge.

To keep forwardRef from coming back, add a lint rule. As of October 2026, eslint-react's no-forward-ref (opens in a new tab) is in its recommended presets, and Biome's noReactForwardRef (opens in a new tab) (since Biome 2.2.5) is opt-in with an unsafe fix. Leave them off in packages that still support React 18.

How do you type ref as a prop in React 19 with TypeScript?

Most of the time you type nothing, because ComponentProps<"button"> includes ref. You add ref?: Ref<HTMLButtonElement> only when you write the props type yourself. The other type changes are deletions and renames:

React 18 patternReact 19 replacement
forwardRef<HTMLInputElement, Props>(...)ref?: Ref<HTMLInputElement> in Props, or ComponentProps<"input">
ComponentPropsWithoutRef<"input"> for a wrapperComponentProps<"input"> (the old type strips the ref you now pass on)
ComponentPropsWithoutRef<typeof Root> plus ElementRef<typeof Root>ComponentProps<typeof Root>, and ComponentRef<typeof Root> when you need the node type (ElementRef is deprecated)
child.ref on a React elementchild.props.ref
useRef<HTMLInputElement>()useRef<HTMLInputElement>(null) (an argument is now required)
ref={(node) => (el = node)}ref={(node) => { el = node; }} (a returned value now reads as a cleanup)

The last row trips people up the day they upgrade @types/react. Ref callbacks may return a cleanup function now, so an arrow function that implicitly returns the node fails with Type 'HTMLDivElement | null' is not assignable to type 'void | (() => VoidOrUndefinedOnly)'. Add braces and it compiles.

When the props are your own, declare the ref like any other prop:

tsx
import type { ReactNode, Ref } from "react";
type IconButtonProps = {
label: string;
icon: ReactNode;
onPress?: () => void;
ref?: Ref<HTMLButtonElement>;
};
export function IconButton({ label, icon, onPress, ref }: IconButtonProps) {
return (
<button ref={ref} type="button" aria-label={label} onClick={onPress}>
{icon}
</button>
);
}

Generic components get their type parameter back

Generic components gain the most from ref as a prop in React 19. forwardRef returns a component that is not generic, so a typed list needed a cast to get its <T> back:

tsx
// React 18: forwardRef drops <T>, so the signature is cast back on
export const List = forwardRef(ListInner) as <T>(
props: ListProps<T> & { ref?: Ref<HTMLUListElement> },
) => ReturnType<typeof ListInner>;

Now it is a plain generic function, and T is inferred at the call site with the ref still typed:

tsx
import type { ComponentProps, ReactNode } from "react";
type ListProps<T> = Omit<ComponentProps<"ul">, "children"> & {
items: T[];
getKey: (item: T) => string;
renderItem: (item: T) => ReactNode;
};
export function List<T>({ items, getKey, renderItem, ...props }: ListProps<T>) {
return (
<ul {...props}>
{items.map((item) => (
<li key={getKey(item)}>{renderItem(item)}</li>
))}
</ul>
);
}
// <List ref={listRef} items={customers} getKey={(c) => c.id} renderItem={(c) => c.name} />

Wrapper layers pass the ref without extra code

In our Button the ref never appears by name. ButtonProps is Omit<ComponentProps<"button">, "color" | ...> plus our props. The component reads them through useDefaults("button", buttonProps), which layers app-wide defaults from UIProvider under the props passed in, and spreads the rest onto motion.button. With forwardRef the ref would arrive as a second argument, and every layer between the caller and the element would need its own way to carry it. As a prop, it is one more key.

How do you merge refs in React 19?

Merge the two refs in a memoized callback ref that returns a cleanup. You need this when the component uses the DOM node itself and the caller wants it too. Our Input is the real case. It needs the node to clear the text with a real input event, put focus back after clearing and hear form resets, and your code needs the same node to focus the field after a failed submit. This is the pattern from components/ui/input.tsx, trimmed to a search field:

tsx
"use client";
import { useCallback, useRef, type ComponentProps, type Ref } from "react";
// hands the node to the caller's ref and returns the matching cleanup
function attachRef<T>(ref: Ref<T> | undefined, node: T) {
if (typeof ref === "function") {
const cleanup = ref(node);
return () => (typeof cleanup === "function" ? cleanup() : ref(null));
}
if (ref) {
const object = ref;
object.current = node;
return () => {
object.current = null;
};
}
return undefined;
}
// writes through the prototype setter so React sees the change, then fires the event onChange listens to
function clearValue(input: HTMLInputElement) {
Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, "value")?.set?.call(input, "");
input.dispatchEvent(new Event("input", { bubbles: true }));
}
export function SearchField({ ref, ...props }: ComponentProps<"input">) {
const inputRef = useRef<HTMLInputElement | null>(null);
const setRef = useCallback(
(node: HTMLInputElement | null) => {
inputRef.current = node;
const detach = attachRef(ref, node);
return () => {
inputRef.current = null;
detach?.();
};
},
[ref],
);
return (
<div className="flex items-center gap-2">
<input ref={setRef} type="search" {...props} />
<button
type="button"
onClick={() => {
if (!inputRef.current) return;
clearValue(inputRef.current);
inputRef.current.focus();
}}
>
Clear
</button>
</div>
);
}

Three details matter:

  • Cleanups. When the node goes away, React 19 calls the cleanup a ref callback returned, instead of calling the callback again with null. So attachRef runs the caller's cleanup if they returned one and falls back to ref(null) if they did not.
  • A stable callback. useCallback keeps setRef the same between renders. A new callback ref on every render makes React detach and reattach it each time. In a StrictMode test, an inline ref callback logged its cleanup and a fresh attach after a plain re-render, while the memoized one stayed attached.
  • A clear React can hear. clearValue goes through the native value setter and fires an input event, so the caller's onChange runs with an empty value, like after a keystroke. Assigning input.value = "" empties the field without telling React or a form library.

Textarea merges refs the same way, so it can measure its own node and grow with its content.

Using it from the outside is a normal ref:

tsx
"use client";
import { useRef } from "react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
export function SignupForm() {
const emailRef = useRef<HTMLInputElement>(null);
return (
<form
noValidate
onSubmit={(event) => {
event.preventDefault();
const email = emailRef.current?.value.trim() ?? "";
if (!email.includes("@")) emailRef.current?.focus();
}}
className="flex flex-col gap-3"
>
<Input ref={emailRef} type="email" label="Email" placeholder="emma.carter@example.com" clearable />
<Button type="submit">Create account</Button>
</form>
);
}

React Hook Form's register passes a ref the same way, so {...register("email")} reaches the <input> with no adapter. The React form components guide covers which fields need Controller instead.

Press Continue with a short title: the demo's own ref moves focus back to the field. Then type and press Escape or tap the clear button: the Input's internal ref empties it and keeps focus
$ npx wingo-ui@latest add input
FreeInput docs

When you expose methods instead of a node, useImperativeHandle(ref, ...) works exactly as before, with ref from props. Our Pro Schema Builder hands out validate() and getJsonSchema() that way; see pricing for what Pro includes.

How does ref as a prop work with asChild and Slot?

Spread the props, ref included, onto Slot, and put the ref on the child when you need the child's type. Radix Slot composes the ref it receives with the child's own ref. In React 19 it reads the child's ref from element.props.ref, so neither one is lost and no warning fires.

The trap is TypeScript. A button component types its ref for <button>, so passing a link ref to the wrapper fails:

tsx
import Link from "next/link";
import { useRef } from "react";
import { Button } from "@/components/ui/button";
export function UpgradeLink() {
const linkRef = useRef<HTMLAnchorElement>(null);
// error: Type 'RefObject<HTMLAnchorElement | null>' is not assignable to type 'Ref<HTMLButtonElement> | undefined'
return (
<Button asChild ref={linkRef}>
<Link href="/pricing">See plans</Link>
</Button>
);
}

Put the ref on the child. Slot merges it with its own, and the type is right:

tsx
"use client";
import Link from "next/link";
import { useEffect, useRef } from "react";
import { Button } from "@/components/ui/button";
export function UpgradeLink({ highlight }: { highlight: boolean }) {
const linkRef = useRef<HTMLAnchorElement>(null);
useEffect(() => {
if (highlight) linkRef.current?.focus();
}, [highlight]);
return (
<Button asChild variant="outline">
<Link ref={linkRef} href="/pricing">
See plans
</Link>
</Button>
);
}

Our Button renders asChild through motion.create(Slot), so two refs want the same node: motion's, for the press spring, and yours. Slot composes both. We mounted this Button in Chromium under StrictMode with a plain anchor as the child: the child's ref received the <a>, and the console stayed empty.

This Button has asChild on, so it renders a link: press it for the same spring dip, and Tab to it for the same focus ring
$ npx wingo-ui@latest add button
FreeButton docs

Two more things break asChild after a migration:

  • A child component that drops the ref. Slot passes ref, className and event handlers to the child, and a component that does not spread its props loses all three. Radix tooltips and popovers find their trigger through that ref, so the content has no element to position against.
  • A homemade Slot that reads child.ref. It still works at runtime, but logs the element.ref error in development, and @types/react 19 no longer declares ref on ReactElement, so TypeScript rejects it too. Read it from props, with a type argument because element props default to unknown in React 19:
tsx
import { isValidElement, type ReactNode, type Ref } from "react";
type WithRef = { ref?: Ref<HTMLElement> };
// React 18 code read children.ref, which logs an error in React 19 development builds
export function getChildRef(children: ReactNode) {
return isValidElement<WithRef>(children) ? children.props.ref : undefined;
}

What should you try next?

Install the two free components from this post and read how they handle refs in your own project:

bash
npx wingo-ui@latest add button input

Both land in components/ui as source you own, along with the field, spinner and helpers they import, and none of those files use forwardRef. For the other half of the stack they target, browse the Next.js and Tailwind CSS guides, starting with Tailwind v4 design tokens.

Components in this post

  • Button

    The button every screen starts with: five variants, six tones, three sizes plus icon sizes, and a loading state that never jumps.

    Free
  • Input

    The single-line text field: icons and addons inside the box, a clear button, a loading spinner, a counter and floating labels.

    Free

FAQ

Is forwardRef deprecated in React 19?

Not formally. The React docs say forwardRef is no longer necessary in React 19 and will be deprecated in a future release. As of React 19.3 it still works and logs no warning, but new function components should take ref as a prop.

How do I type the ref prop in React 19 with TypeScript?

When you wrap one element, use ComponentProps<'button'>, which already includes ref. When you write the props type yourself, add ref?: Ref<HTMLButtonElement>. Stop using ComponentPropsWithoutRef for wrappers, and replace ElementRef with ComponentRef.

Do I still need forwardRef if my library supports React 18?

Yes. React 18 removes ref from props before your component runs, so only forwardRef gives you the ref there. Keep it until React 18 leaves your peer dependencies; forwardRef components still work in React 19.

Does asChild still work without forwardRef?

Yes. Radix Slot reads the child's ref from its props in React 19 and composes it with the ref the Slot receives. Put the ref on the child when you want its real type, such as HTMLAnchorElement for a link rendered through a button component.

Is there a codemod to remove forwardRef?

Yes. The reactjs/react-codemod project ships remove-forward-ref: run npx codemod react/19/remove-forward-ref --target components/ui on a clean branch, then review the diff, especially generic components and props types that still use ComponentPropsWithoutRef.

Do class components get ref as a prop in React 19?

No. A ref passed to a class component still points to the component instance and is not passed in its props.

  • React 19
  • TypeScript
  • forwardRef
  • shadcn/ui
  • Refs

Share

SR

About the author

Serban Rusu

Founder of Wingo UI

Serban Rusu is the founder of Wingo UI. He builds the component library, its CLI and its MCP server, and writes about React interfaces that work well on phones and with AI coding agents.

More from Serban
Keep reading

Related posts

All posts
Building UI with AI agents

Claude Code shadcn Setup: MCP Server, Skill and CLAUDE.md

Claude Code shadcn setup, tested on shadcn 4.21.4: the MCP server, the skill, a second registry in its own folder, a CLAUDE.md block and prompts that work.

SRSerban Rusu·Oct 9, 2026·12 min read
Next.js and Tailwind CSS

Tailwind v4 Design Tokens: Colors, Dark Mode and Contrast

Tailwind v4 design tokens from a production React library: @theme inline, semantic surfaces, status colors, dark mode and a contrast check you can run in CI.

SRSerban Rusu·Oct 9, 2026·19 min read
Comparisons and alternatives

14 shadcn Alternatives in 2026: Price, Mobile and MCP

14 shadcn alternatives checked in October 2026: free and paid UI libraries by license, price, mobile behavior and MCP support, with a pick per use case.

SRSerban Rusu·Oct 9, 2026·19 min read
Newsletter

Get new posts by email

New guides, tutorials and comparisons from the Wingo UI blog, sent when they are published.

No spam. Unsubscribe at any time.

WingoUI

Animated, configurable, mobile-first React components. Copy the source, make it yours, and let your coding agent build with it.

ComponentsTemplatesPricingBlogTheme

Component categories

  • Buttons & Actions
  • Inputs
  • Forms
  • Navigation
  • Overlays
  • Feedback
  • Data Display
  • Tables & Lists
  • Charts & Stats
  • Layout
  • Media
  • AI Kit
  • Text & Effects
  • Mobile
  • Commerce
  • Marketing Sections
  • Blocks
  • Hooks & Utilities
Wingo UI