Skip to main content
ASoc
Tutorial

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.

The ASoc Team8 min read

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

ChangeDoes it break anything?What it costs you
ref is an ordinary prop on function componentsNo — forwardRef still worksEvery forwardRef wrapper in your copied components is now dead weight
React.ElementRef deprecated in favour of ComponentRefNo — it still compilesDeprecation warnings across the files shadcn generated for you
Peer dependencies on react@19No longer — all current versions allow ^19Nothing, if you are installing fresh in 2026
propTypes removed for function componentsNot for shadcn — it never used themNothing

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:

PackageVersionReact peer range
@radix-ui/react-dialog1.3.1^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
@radix-ui/react-slot1.5.0^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
react-remove-scroll2.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-otp1.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:

  1. React.forwardRef<Ref, Props>((props, ref) => …) becomes a plain function taking { ref, ...props }.
  2. React.ElementRef<T> becomes React.ComponentRef<T>, or disappears entirely when you type props as React.ComponentProps<T> (which already includes ref in React 19).
  3. Component.displayName = "…" goes — a named function already has a name in React DevTools.
  4. 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

MistakeSymptomFix
npm install --force by habitReal peer conflicts silencedRead the conflict; current Radix/vaul/cmdk all allow ^19
Expecting the registry to migrate your filesDeprecated types linger for yearsCopy-in code is yours; convert it yourself
ComponentPropsWithoutRef plus a ref propref missing from your prop typeUse ComponentProps<T> — in React 19 it includes ref
Keeping displayName after dropping forwardRefDead assignment; lint noiseDelete it; named functions name themselves
forwardRef on every new componentGenerics and wrappers nobody needsAdd ref to props only where a parent needs the node
Mixing React 18 and 19 @types/react in a monoreporef type errors that look like React's faultPin one @types/react across workspaces
Assuming a ref works on a Server ComponentRef is always nullRefs 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.

Keep reading

Tutorial9 min read

Shopify Advantages, Priced in the Code You Write Without Them

Checkout, order records, apps, the admin: each Shopify advantage measured against the 1,545 lines of TypeScript and 271 of SQL it took to replicate a subset.

Read more
Tutorial8 min read

Shopify International Markets vs One Price and a Merchant of Record

Eight Shopify Markets settings audited against a digital storefront: five are empty, VAT belongs to the MoR, and one currency fallback was printing a dollar sign that lied.

Read more