# React 19 forwardRef Migration: ref as a Prop in TypeScript

> React 19 forwardRef migration in TypeScript: take ref as a regular prop, type it with ComponentProps, merge refs with cleanups and keep asChild working.

- Author: [Serban Rusu](https://wingo-ui.com/blog/authors/serban), Founder of Wingo UI
- Published: Oct 9, 2026
- Category: [Next.js and Tailwind CSS](https://wingo-ui.com/blog/category/nextjs-tailwind)
- Reading time: 9 min
- Canonical: https://wingo-ui.com/blog/react-19-forwardref-ref-as-prop

## 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.

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](https://react.dev/blog/2024/12/05/react-19#ref-as-a-prop) says new function components "will no longer need `forwardRef`" and that React will "deprecate and remove `forwardRef`" in future versions. The [forwardRef reference](https://react.dev/reference/react/forwardRef) 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](https://wingo-ui.com/docs/installation), 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](https://github.com/reactjs/react-codemod#remove-forward-ref), 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](https://ui.shadcn.com/docs/tailwind-v4) 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](https://wingo-ui.com/blog/update-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](https://eslint-react.xyz/docs/rules/no-forward-ref) is in its recommended presets, and [Biome's noReactForwardRef](https://biomejs.dev/linter/rules/no-react-forward-ref/) (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 pattern | React 19 replacement |
|---|---|
| `forwardRef<HTMLInputElement, Props>(...)` | `ref?: Ref<HTMLInputElement>` in `Props`, or `ComponentProps<"input">` |
| `ComponentPropsWithoutRef<"input">` for a wrapper | `ComponentProps<"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 element | `child.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](https://wingo-ui.com/components/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](https://wingo-ui.com/components/ui-config) 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](https://wingo-ui.com/components/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](https://wingo-ui.com/components/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](https://wingo-ui.com/blog/react-form-components-guide) covers which fields need `Controller` instead.

> Live demo (Input): 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. Try it at [Input](https://wingo-ui.com/components/input) and install it with `npx wingo-ui@latest add input`.

When you expose methods instead of a node, `useImperativeHandle(ref, ...)` works exactly as before, with `ref` from props. Our Pro [Schema Builder](https://wingo-ui.com/components/schema-builder) hands out `validate()` and `getJsonSchema()` that way; see [pricing](https://wingo-ui.com/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.

> Live demo (Button): 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. Try it at [Button](https://wingo-ui.com/components/button) and install it with `npx wingo-ui@latest add button`.

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](https://wingo-ui.com/blog/category/nextjs-tailwind), starting with [Tailwind v4 design tokens](https://wingo-ui.com/blog/tailwind-v4-design-tokens).

## Components in this post

- [Button](https://wingo-ui.com/components/button) (Free): The button every screen starts with: five variants, six tones, three sizes plus icon sizes, and a loading state that never jumps. Install: `npx wingo-ui@latest add button`
- [Input](https://wingo-ui.com/components/input) (Free): The single-line text field: icons and addons inside the box, a clear button, a loading spinner, a counter and floating labels. Install: `npx wingo-ui@latest add input`

## 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.

---

Source: https://wingo-ui.com/blog/react-19-forwardref-ref-as-prop
