React 19 says goodbye to forwardRef and puts meta tags in components
What React 19 changed from 18: ref as a prop, Actions and native meta tags, and why my own projects are still on 18.
#React #Frontend #Refactoring
A while ago I wrote a post about injecting SPA meta tags directly on the server. After writing it, I was reading the React 19 docs and found out that React now supports meta tags directly in components . Something I'd struggled to build on the server side, React now does as a built-in feature, so I figured I should take a proper look. In this post I'll go over what React 19 changed from 18, and how that could cut down my code. At the end I'll also note why, even so, I still haven't been able to move my projects to 19. Meta tags are just rendered in the component This was the first thing that hit home. In React 19, if you just render a title or meta inside a component, React takes care of hoisting it into the document's head . You don't need a library like react-helmet. function BlogPost({ post }) { return ( article title {post.title} | Jaeyonging Blog /title meta name="description" content={post.descr} / meta property="og:title" content={post.title} / h1 {post.title} /h1 {/* ... body ... */} /article ); } But there's something not to get confused about here. These get inserted after the JavaScript has run . In other words, crawlers and the KakaoTalk bot still can't see them. So the work I did injecting meta on the server (the previous post) is still needed . The two don't compete, they complement each other. React 19 covers the browsers people use, and server injection covers the crawlers. For my blog it goes like this. Right now, every time the route changes, a component called CanonicalManager digs through document.head directly and rewrites canonical and og:url. With React 19 that code shrinks to a few lines of tags. Saying goodbye to forwardRef Up to React 18, passing a ref to a child component meant wrapping it in forwardRef . It was more of a nuisance than it sounds. From React 19 on, ref is just received like a normal prop. // React 18: had to wrap it in forwardRef const Input = forwardRef((props, ref) = { return input ref={ref} {...props} / ; }); // React 19: just take it as a prop and that's it function Input({ ref, ...props }) { return input ref={ref} {...props} / ; } Once you unwrap the components that used to be wrapped in forwardRef, the code gets one layer thinner each time. It may not look like much, but when wrappers like this pile up, the component tree gets hard to read. The generic types of forwardRef are the kind you have to look up every time you use them, so this is a bigger change than it seems. Actions: React holds the form's loading and error state for you This is the biggest change going from 18 to 19. Before, when submitting a form, I made the isLoading and error state myself with useState and switched them on and off one by one with try/catch. In React 19, if you just pass an async function to the form's action, it manages pending (in progress) and error automatically for you. Hooks like useActionState, useFormStatus and useOptimistic came in along with it. // I don't have to create the submitting state myself const [error, submitAction, isPending] = useActionState( async (prev, formData) = { const res = await save(formData); if (res.error) return res.error; return null; }, null ); // just set form action={submitAction} and isPending changes on its own form action={submitAction} input name="title" / button disabled={isPending} {isPending ? 'Saving' : 'Save'} /button {error p {error} /p } /form Code I used to manage by hand, like "it's submitting, so disable the button", disappears entirely. useOptimistic in particular makes it easy to change the screen first before waiting for the server response (an optimistic update) and roll it back if it fails. It suits actions that are likely to succeed, like posting a comment. The use() hook Also new is use() , which can unwrap a promise directly during render. Used together with Suspense, it lets you get data as if you were awaiting it. It can be called inside conditionals too, so it's more flexible than the existing rules of hooks. import { use, Suspense } from 'react'; // create the promise outside the component const postPromise = fetch('/api/getBlog?bid=66').then((r) = r.json()); function Post() { const post = use(postPromise); // unwrap it right in render. If it isn't done yet, Suspense catches it return h1 {post.title} /h1 ; } function Page() { return ( Suspense fallback={ p Loading /p } Post / /Suspense ); } The thing to watch out for is that you must not create the promise fresh inside render. A new fetch goes out on every render and Suspense keeps triggering. So you usually create it outside like above, or use it with a library that has a cache (something like React Query). This example is something I put together from the docs, not something I've actually put into my own projects. I can't upgrade on my own Even though 19 looks good, there's a reason I can't upgrade right away. Getting rid of forwardRef is about my own code, and it doesn't help if the libraries I use are still written with forwardRef . UI libraries, animation, editors and the like mostly pass refs internally. If even one of them doesn't support 19 yet, the console fills up with warnings or it doesn't run at all. My blog alone depends on react-quill, lottie-react and framer-motion, so those have to be ready first. So my projects are all still on 18. When I checked in September 2026, as I was revising this post, the blog, the portfolio, the crypto site and the monitoring server were on 18.2.0, and even the most recent ones, the travel planner and Ieum, are on 18.3.1. The reason I didn't start even the new ones on 19 is mostly to avoid the library problem I mentioned above ahead of time. I'm waiting for the whole dependency tree to catch up just to cut a few lines of code. So for now I only check once a quarter whether the main dependencies support 19. Once they're ready, the upgrade itself is a day's work.