I want you to picture something. It’s a Tuesday. You’re at your desk. Coffee’s still warm. You open your terminal, bump a version number, hit install, and watch your entire project catch fire.
That’s a React upgrade.
It always sounds straightforward — a quick version bump, npm install, maybe mute a few warnings. But if you’ve actually done it, you know the truth. The version number is the easy part. Everything that comes after is the part nobody warns you about.
A few months back, our Frontend chapter pulled off a full upgrade from React 17 to React 18 during a hackathon at our headquarters in Athens. The whole team, working toward a common goal. It was challenging, messy, and — I’ll admit — actually fun.
React 18 was working fine after that. Life was good. But the ecosystem, it never sits still, does it? Libraries started hinting that React 19 was the “expected” baseline. TypeScript types were drifting in that direction. Deprecation warnings started showing up like passive-aggressive Post-it notes on your monitor.
Nobody wants to be the team still running the old version when the next wave hits. That’s how you end up three versions behind, staring down a rewrite.
So this time, I decided to do it solo. Just me, a codebase, and what I assumed would be a quiet afternoon.
I was wrong.
The Challenges
Dependency Conflicts
Here’s the thing about upgrading React. You’re not upgrading React. You’re upgrading everything that ever touched React.
The moment I bumped to React 19, the whole project started screaming. React Router needed a version bump. Nearly all unit tests failed. Testing libraries demanded newer React DOM APIs. ESLint plugins lost their minds. I thought this would be easier than the 17-to-18 jump. Instead, I was playing whack-a-mole with a cascade of small fires, each one revealing two more underneath.
I thought about quitting. Just reverting the branch, closing the laptop, going for a walk. Pretending none of it happened.
I didn’t. But I thought about it.
The Bugs That Were Always There
Here’s a dirty secret about React upgrades: half the bugs you “discover” aren’t new. They were always there. You just never had a reason to look.
Take this example. On React 18, this effect worked without complaints — reset was replacing the entire form state with just { event: { type } }:
useEffect(() => {
const preservedEventType = formValuesWatch.event?.type;
reset({ event: { type: preservedEventType } });
}, [eventTypeWatch]);
It worked. It shipped. Nobody questioned it. But it was broken the whole time. Every time this effect ran, it was throwing away the rest of your form values and replacing everything with a minimal object. React 18’s StrictMode was already double-firing effects in development — which meant this bug was being triggered twice per render cycle. But TypeScript didn’t complain about the shape mismatch, and the form seemed to work, so nobody noticed. Or maybe we just looked the other way.
Then we upgraded to React 19 and updated @types/react. The stricter types lit up like a Christmas tree. Suddenly TypeScript cared about what we were passing to reset(). And once we started looking at the flagged code, the real bug became obvious — this effect had been silently corrupting form state all along. The upgrade didn’t break it. It just ripped off the bandage.
The fix was to stop being lazy about it:
useEffect(() => {
const preservedEventType = formValuesWatch.event?.type;
const currentValues = getValues();
reset({
...currentValues,
event: { ...currentValues.event, type: preservedEventType },
});
}, [eventTypeWatch]);
Grab the current values. Spread them. Override only what you need. Don’t blow away things you didn’t mean to touch. Simple, once you see it. But you have to see it first — and sometimes it takes an upgrade to force you to look.
TypeScript Got Stricter As Well
This is the part where it gets fun. And by fun, I mean the kind of fun where you open your IDE and every single file has red squiggles. Everywhere. Like your codebase developed a rash overnight.
React 19 shipped with completely overhauled TypeScript types, and the changes were not cosmetic. ReactChild, ReactText, a handful of other utility types you probably used without thinking — gone. Just removed. If your codebase referenced them, even indirectly through a shared component library, you got a wall of errors the moment you updated @types/react.
Then there was the ref situation. React 19 finally lets you pass ref as a regular prop. No more forwardRef wrapping. That’s the good news. The bad news is that on the TypeScript side, ref callback return types got stricter. If you had ref callbacks with implicit returns — an arrow function that accidentally returned something — TypeScript now rejected them. Across a large codebase, these “small” changes multiplied into hundreds of type errors.
useRef got pickier too. Call it without an argument? TypeScript error. You need to explicitly pass null or undefined now. RefObject no longer includes null by default, so any code that assumed ref.current could be null needed touching.
The React team published a codemod (types-react-codemod) that handled some of this. The keyword being some. The more custom your type patterns, the more you were on your own.
The forwardRef Farewell
This one deserves its own section because it touched everything.
With React 19 treating ref as a regular prop, forwardRef becomes unnecessary. In theory, that’s a beautiful simplification. In practice, we had dozens of components wrapped in forwardRef, each with its own generic type signatures, its own quirks, its own reasons for existing.
Removing forwardRef meant restructuring every single one. Pull ref into the props interface. Update the type signatures. Make sure the parent components that pass refs still compile. It was mechanical, repetitive, mind-numbing work — the kind where you get sloppy on component number 47 because your eyes have gone glassy and everything looks the same.
The kind of work that breaks you if you let it.
Enter Claude Code
A few hours in. Buried in type errors. Tests failing everywhere. That quiet voice in your head saying this solo upgrade was a terrible idea.
That’s when I decided to throw Claude Code at it.
I’d used it before for smaller things — generating boilerplate, explaining someone else’s code, the usual. But I’d never thrown it at a full-scale migration. A real mess. The kind of mess where you don’t even know where to start.
Turns out, that’s exactly where it comes alive.
Bulk Code Migrations
The forwardRef removal was the first real test. Dozens of components. All needing the same structural surgery: unwrap forwardRef, move ref into the props type, clean up the generics. By hand, this is the kind of task that takes hours and introduces bugs at a steady, reliable rate.
With Claude Code, I described the pattern once. It understood the structure. And then it just… did it. Across the codebase.
What got me was how it adapted. It didn’t do a dumb find-and-replace. Components with complex generic types got different treatment than simple ones. It caught cases where ref was being used in non-standard ways and flagged them for manual review instead of blindly transforming them. It was thinking about the code, not just processing it.
Same approach worked for the deprecated type references. Every instance of ReactChild that needed to become React.ReactElement | number | string? Handled. File by file. Respecting existing imports. Not introducing noise.
Debugging Type Errors
This is where Claude Code really earned its place. After the bulk migrations, I still had a backlog of type errors that weren’t mechanical. Nuanced things — useReducer inference changes, ref callback return types, third-party library type mismatches.
Normally, each of these is a 20-minute rabbit hole. Google the error. Find a GitHub issue from eight months ago. Read through 40 comments to find the one that actually has the answer. Try three different approaches. Repeat.
Instead, I could feed Claude Code the error and the surrounding code and get back a clear explanation of why it broke and how to fix it. Not just “change this line.” It understood the difference between “this broke because React 19 changed the type definition” and “this was always wrong, React 18 just let you get away with it.”
One that sticks out: we had a custom hook that returned a ref callback. After the upgrade, TypeScript hated the return type. Claude Code immediately identified the issue — React 19 introduced ref cleanup functions, so you can now return a cleanup function from a ref callback, similar to useEffect. The implicit return in our arrow function was being interpreted as a cleanup function. The fix was a one-liner. Add explicit curly braces. Done.
Understanding why that was the fix? That would have cost me half an hour on my own. Maybe more.
Fixing Broken Tests
Nearly all our unit tests were failing. This was probably the most daunting part, because test failures are a special kind of demoralizing — you can’t tell at a glance whether it’s a real problem or just noise from the upgrade.
The failures fell into categories. Testing library API changes. StrictMode behavior affecting test output. Genuine regressions from our refactoring. The tricky part is figuring out which is which.
Claude Code helped me triage at speed. Feed it a failing test with its error output. It would tell me: this is a testing library compatibility issue (update @testing-library/react, adjust the assertions), this is a StrictMode double-render thing (your test was relying on render count), this is an actual bug you introduced (fix it).
For the testing library issues, it helped me batch-update patterns across the entire test suite. Replacing deprecated query methods. Updating async utilities. Adjusting mock setups that assumed React 18 rendering behavior. The boring, necessary work that eats your day if you do it by hand.
What I Learned
A few things I’m taking away from this:
Upgrade to 18.3 first. If you haven’t already, install react@18.3 before you even think about 19. It’s identical to 18.2 but adds deprecation warnings for everything that will break. Think of it as a damage report before the actual damage.
Don’t underestimate the type changes. The runtime breaking changes in React 19? Manageable. The TypeScript type changes? That’s where the real volume lives, especially in a large codebase. Run the official types-react-codemod early — it won’t catch everything, but it’ll clear the surface-level wreckage. And if you’re on a large project and can’t afford to fix every type error in one go, look into Betterer. It lets you snapshot your current type errors and enforce that things only get better over time — no new errors allowed, but existing ones can be chipped away incrementally across PRs. Merge the upgrade, unblock your team, and clean up at your own pace. No shame in that.
The upgrade didn’t break your code. It exposed what was already broken. Half the bugs we found during the migration existed in React 18 too — we just never had a reason to look. Stricter types forced us to revisit code we hadn’t touched in months, and that’s where the real issues were hiding. Don’t blame the upgrade. Thank it.
AI tooling has crossed a line for migrations. I’ll be honest — I was skeptical. Migrations aren’t about writing new code. They’re about understanding why existing code breaks under new rules and applying the right fix without creating new problems. That felt too nuanced for an AI tool. I was wrong. Claude Code didn’t replace my judgment, but it cut the mechanical work and research time dramatically. The ratio shifted from “90% investigation, 10% fixing” to something much more sane.
Final Thoughts
Would I do the solo upgrade again? Yes. But only because Claude Code made it possible. Without it, this would have been days of documentation, GitHub issues, and Stack Overflow threads. The kind of work that makes you question your career choices.
Instead, it was one intense session where I could focus on the decisions that actually mattered and hand off the repetitive, soul-crushing investigation work to something that didn’t mind doing it.
The React ecosystem keeps moving. These upgrades aren’t optional forever. You put them off, they compound. The gap gets wider. The next upgrade gets worse.
The good news? The tooling for managing migrations — from the React team’s codemods and incremental releases, to AI assistants that actually understand what they’re looking at — is getting better, fast.
If you’ve been sitting on the React 19 upgrade, my advice is simple: stop waiting. It’s not going to get easier on its own. Grab the best tools you have and start pulling the thread.
Your future self will thank you. Or at least stop resenting you.
