shadcn/ui on React 19: Nothing Breaks, and That Is the Problem
Both the forwardRef and the ref-as-prop shape typecheck on React 19.2.4. Eight peer ranges measured today, and the cleanup copy-in leaves you owning.
shadcn/ui works on React 19, and in late 2026 the install friction is gone: every dependency the registry pulls now declares a React 19-compatible peer range. What React 19 changes is not compatibility but ceremony — forwardRef is no longer needed, and because shadcn copies components into your repo, the old wrapper code stays yours until you delete it.
That last part is the whole story. A library would have shipped the cleanup in a version bump. Copy-in components cannot, so the migration is a chore you own. Below are the three things that actually changed, the peer ranges I pulled from the npm registry today, and two typechecks I ran against the React 19.2.4 / @types/react 19.2.17 installed in this repository.
What React 19 changes for a shadcn codebase
| Change | Does it break anything? | What it costs you |
|---|---|---|
ref is an ordinary prop on function components | No — forwardRef still works | Every forwardRef wrapper in your copied components is now dead weight |
React.ElementRef deprecated in favour of ComponentRef | No — it still compiles | Deprecation warnings across the files shadcn generated for you |
Peer dependencies on react@19 | No longer — all current versions allow ^19 | Nothing, if you are installing fresh in 2026 |
propTypes removed for function components | Not for shadcn — it never used them | Nothing |
Only the middle column matters for a decision, and it is "no" four times. The upgrade is not a compatibility event; it is a cleanup you can schedule.
Peer dependencies: measured, not remembered
The npm install --force advice all over the internet dates from late 2024, when Radix and friends had not yet widened their ranges. I queried the registry on 2026-10-19 for the packages shadcn's registry actually pulls:
| Package | Version | React peer range |
|---|---|---|
@radix-ui/react-dialog | 1.3.1 | ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc |
@radix-ui/react-slot | 1.5.0 | ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc |
react-remove-scroll | 2.7.2 | ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc |
vaul (Drawer) | 1.1.2 | ^16.8 || ^17.0 || ^18.0 || ^19.0.0 || ^19.0.0-rc |
cmdk (Command) | 1.1.1 | ^18 || ^19 || ^19.0.0-rc |
input-otp | 1.5.0 | ^16.8 || ^17.0 || ^18.0 || ^19.0.0 || ^19.0.0-rc |
react-day-picker (Calendar) | 10.0.2 | >=16.8.0 |
recharts (Chart) | 3.10.1 | ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 |
All eight resolve against React 19 without a flag. If you are still typing --force out of habit, stop: it suppresses every peer conflict in the tree, including the one that would have told you something real. Reach for --legacy-peer-deps or --force only after reading the conflict npm printed, and re-check the range before assuming it is still wrong.
forwardRef is now optional, and that is the migration
In React 19 a function component can take ref as a normal prop. I wrote the modern shape and typechecked it with this repo's TypeScript against its installed React types:
import type { ComponentProps, Ref } from "react";
function Input({ ref, ...props }: ComponentProps<"input">) {
return <input ref={ref} {...props} />;
}
export function Field({ inputRef }: { inputRef: Ref<HTMLInputElement> }) {
return <Input ref={inputRef} name="email" />;
}
tsc --noEmit --strict exits 0. No forwardRef, no generic parameters, no displayName.
Then I typechecked the shape shadcn generated before React 19 — forwardRef plus ElementRef plus ComponentPropsWithoutRef:
const Input = React.forwardRef<
React.ElementRef<"input">,
React.ComponentPropsWithoutRef<"input">
>(({ className, ...props }, ref) => (
<input ref={ref} className={className} {...props} />
));
Input.displayName = "Input";
That also exits 0. Nothing breaks. The old components keep compiling and keep working, which is exactly why this migration gets deferred forever.
What you lose by deferring is visible in the installed type definitions. node_modules/@types/react/index.d.ts marks the two types those wrappers lean on:
/**
* @deprecated Use `ComponentRef<T>` instead
*/
type ElementRef<C extends …> = ComponentRef<C>;
/**
* @deprecated Use `Ref` instead. String refs are no longer supported.
*/
type LegacyRef<T> = Ref<T>;
The same file carries a franker note about where this is heading, in a comment at line 1094: "hopefully forwardRef is removed from React before we do."
So the mechanical conversion, per component:
React.forwardRef<Ref, Props>((props, ref) => …)becomes a plain function taking{ ref, ...props }.React.ElementRef<T>becomesReact.ComponentRef<T>, or disappears entirely when you type props asReact.ComponentProps<T>(which already includesrefin React 19).Component.displayName = "…"goes — a named function already has a name in React DevTools.- The generic parameters go, which is usually the biggest readability win.
Copy-in means you own the ceremony
This is the part people underweight when choosing shadcn. The components are not a dependency; they are your source files. A npm update fixes nothing here, and the registry will not reach into the twenty files it generated for you last year.
The honest comparison is to a codebase with no component library at all. This storefront has 92 component files and 13 runtime dependencies, none of them a UI library. Across all 92: zero forwardRef calls, 24 client components, and six files that use a ref at all. Refs are not a thing you need everywhere — they are a thing you need where you touch the DOM:
// src/components/molecules/DownloadMenu.tsx
const wrapperRef = useRef<HTMLDivElement>(null);
const buttonRef = useRef<HTMLButtonElement>(null);
useEffect(() => {
if (!open) return;
const onPointerDown = (e: PointerEvent) => {
if (!wrapperRef.current?.contains(e.target as Node)) close();
};
// Escape closes the panel and returns focus to the trigger.
const onKey = (e: KeyboardEvent) => {
if (e.key !== "Escape") return;
e.stopPropagation();
close();
buttonRef.current?.focus();
};
document.addEventListener("pointerdown", onPointerDown);
// …
}, [open]);
Outside-click dismissal and focus return — two refs, held locally, never forwarded. src/components/molecules/PreviewModal.tsx is the heavier case: dialogRef and stageRef drive a focus trap that also has to cope with a cross-origin iframe stealing focus. Again, no ref crosses a component boundary.
That is the ratio worth checking before you adopt a pattern: in a real app, ref forwarding is needed by a handful of primitives. shadcn applied the forwardRef wrapper uniformly because a registry cannot know which of your components will be composed — and in React 19, uniformly is zero times.
The conversion, verified end to end
Here is the same Input after the conversion, including the one place a ref type is still worth naming. This also typechecks clean under --strict:
function Input({ className, ref, ...props }: React.ComponentProps<"input">) {
return <input ref={ref} className={className} {...props} />;
}
function Parent() {
const node = React.useRef<React.ComponentRef<"input">>(null);
return <Input ref={node} />;
}
Three lines shorter than the version it replaces, with the generics and the displayName gone and ComponentRef in place of the deprecated ElementRef.
To find every file still carrying the old shape, one grep covers all three markers:
grep -rn "forwardRef\|ElementRef\|displayName" src/
In this repository that returns nine hits and none of them are React's: every one is a displayName string on the account-settings form and the profile row behind it (src/lib/actions/account.ts, src/app/dashboard/page.tsx). Run it on a shadcn codebase and the count tells you the size of the chore — roughly one hit per generated primitive, times three.
If you are weighing the copy-in model itself rather than its React 19 details, shadcn vs. Tailwind has the dependency count from this repo, and shadcn/ui on Tailwind v4 covers the other half of a 2026 upgrade — the token and cascade changes that land at the same time.
Mistakes and how they show up
| Mistake | Symptom | Fix |
|---|---|---|
npm install --force by habit | Real peer conflicts silenced | Read the conflict; current Radix/vaul/cmdk all allow ^19 |
| Expecting the registry to migrate your files | Deprecated types linger for years | Copy-in code is yours; convert it yourself |
ComponentPropsWithoutRef plus a ref prop | ref missing from your prop type | Use ComponentProps<T> — in React 19 it includes ref |
Keeping displayName after dropping forwardRef | Dead assignment; lint noise | Delete it; named functions name themselves |
forwardRef on every new component | Generics and wrappers nobody needs | Add ref to props only where a parent needs the node |
Mixing React 18 and 19 @types/react in a monorepo | ref type errors that look like React's fault | Pin one @types/react across workspaces |
| Assuming a ref works on a Server Component | Ref is always null | Refs need "use client" — 24 of 92 files here |
Frequently asked questions
Is shadcn/ui compatible with React 19?
Yes. Both the pre-19 forwardRef shape and the React 19 ref-as-prop shape typecheck clean on React 19.2.4 with @types/react 19.2.17.
Do I still need --force to install shadcn components?
Not for the current versions. Every package checked above declares a React 19 peer range as of October 2026.
Should I rewrite all my shadcn components to drop forwardRef?
Not urgently — nothing breaks. Convert each file the next time you touch it, and stop writing new forwardRef wrappers today.
Does ComponentProps really include ref now?
Yes. That is why the modern shape above destructures ref straight out of ComponentProps<"input"> and still typechecks under --strict.
Templates with the component layer already settled
The React 19 cleanup is cheap per file and expensive across forty of them, and it only exists because the components live in your repo. The landing-page templates below ship components written for React 19 from the start — ref where a ref is needed, nothing where it is not.
