There’s a special kind of technical debt that doesn’t look like debt at all. The app works. The screens load. Nobody’s yelling. So we tell ourselves the classic lie:

“It’s fine. We’ll upgrade later.”

But “later” is where upgrades go to become migrations.

Staying behind on library versions is rarely painful day-to-day. The pain shows up slowly and quietly: docs stop matching what you have, examples on the internet don’t apply, new teammates assume a newer API, and tiny workarounds pile up because “that’s how this old version needs it.” And then one day you’re not upgrading a library, you’re upgrading a whole era of decisions.

That’s where we were: our client repo was still on TanStack Query v3, while the world had moved on to v5.

The Spark: Amsterdam, June 2025

This upgrade didn’t start as a roadmap initiative or a “mandatory improvement.” It started in June 2025, at React Summit Amsterdam.

I watched Tanner Linsley (the creator of TanStack Query) talk about where the library is going, and why. I also joined a hands-on workshop led by TkDodo (Dominik Dorfmeister), one of the main contributors.

And that combination did something dangerous to my brain: it made the future feel close. Suddenly, staying behind didn’t feel “safe” anymore, it felt like we were choosing to be stuck.

I came back with the kind of motivation that only conferences provide: equal parts inspiration and “I can totally do this in a week.”

Spoiler Alert: It didn’t take a week

Why We Migrated (Without the Marketing Fluff)

Even if you’ve never heard of TanStack Query (or even React) this part is relatable: some upgrades are worth doing because they make the system safer, clearer, and easier to change.

For us, v5 had a few big reasons to move:

  • Our client is TypeScript-first, and v5 improves the overall type-safety story (fewer “trust me” moments).
  • It aligns better with modern React patterns (including improved suspense/error support in the ecosystem around it).
  • It encourages a more intuitive flow for keeping data in sync, especially around invalidations and organizing query keys.
  • It comes with new structure, improved consistency, and generally a more “current” mental model.

And yes: no one wants to be left behind, especially on a library that sits at the core of how data flows through the app.

That’s the practical version. The emotional version is simpler:

If this thing is part of our app’s foundation, it shouldn’t be fossilized.

What Actually Changes in an Upgrade Like This (The Vibe, Not the Details)

Here’s the trick with explaining upgrades: people don’t care about the exact API changes and they shouldn’t. What matters is the shape of the pain.

The v3 → v5 jump had a theme: more consistency, fewer “choose your own adventure” patterns.

In plain language, we moved from “there are multiple ways to call this and they all kinda work” to “there’s one clear way to do it.” In practice, that meant a lot of the code had to be rewritten into a more unified structure.

Some of the internal vocabulary changed too (for example, what used to be described as “loading” moved toward “pending”). Not exciting!? Sure, until you realize how many places in a mature product use those states for spinners, skeleton loaders, button disabling, and empty states.

So even small changes ripple into real UI behavior.

The Elephant in the Room: “Wait… Where Did My Callbacks Go?”

This was the moment I knew the migration was going to turn into a story.

If you’ve ever used a “data fetching” tool, you’ve probably used callbacks like:

  • “when it succeeds, do this”
  • “when it fails, do that”
  • “when it finishes, clean up”

In v5, query callbacks were deprecated. TanStack’s reasoning (paraphrased) is basically:

They can create confusing side-effects; prefer using useEffect or dependent queries instead.

Now, that’s a fair philosophy. But in a large existing codebase, it also means:

  • You can’t just upgrade the package.
  • You have to decide what your new “standard way” is.
  • And you have to apply it everywhere fairly consistently.

So we did what we’ve learned (and, frankly, what big codebases often need and should do!):

We created facades

Instead of rewriting the whole app to adopt the new callback philosophy directly in every file, we introduced our own wrappers (facades) for useQuery and useInfiniteQuery.

Under the hood, they follow the recommended approach (via useEffect and status/data checks), but from the developer’s point of view, they act as the familiar “house style” of our repo:

  • Import our own hook, not directly from the library.
  • Keep behavior consistent.
  • Make future changes easier because we control the interface.

It’s the same spirit as other abstractions we already have (like how we’ve wrapped other libs to add our own conventions).

This ended up being one of the most important parts of the migration, not because it’s “clever,” but because it reduces confusion and keeps the codebase cohesive.

How the Migration Took Life (aka: The Montage)

I won’t pretend this was a calm, linear process. It was more like a series of increasingly honest conversations with reality.

Step 1: Cursor was a big part of it

I fed the agent documentation, asked it to handle the “mechanical” stuff (renames, repetitive edits, the new unified syntax), and it did help, especially early on.

And then we hit the classic wall: large codebase + many patterns + many edge cases = AI starts hallucinating and confidently breaks things.

So the vibe shifted from “AI will do this for me” to “AI will do the tedious parts while I supervise like a tired detective.”

Step 2: Linting as a compass

At some point it became pure grind in the best sense: npm run lint, click errors one by one, guide the tool, fix things manually when needed, repeat.

Not glamorous, but effective. Like cleaning a kitchen by starting from the most visible mess.

After these first steps, and while I was working on this, in between my “planned tasks” and some of it on my free time, two weeks flew by.

The reality hit me then. It’s gonna take a year if I move like this. So I did the only thing that can help me finish this. I escalated the issue and I bought myself some time off my “regular work”, in order to see this through.

Step 3: The facade fix

Once the obvious deprecations were handled, we solved the “callbacks” problem properly by introducing the wrappers. That was a turning point: suddenly the rest of the migration felt possible.

At this point, I was nearly a month in but I could actually see the light at the end of the tunnel.

Step 4: Running the client

There’s a moment in every migration where you finally get to:

build,

load the app,

click around…

…and you think, “We did it.”

That moment is a liar.

Step 5: Bring in QA (and humility)

This is where the real work started. Compilers can’t catch logic misunderstandings. QA can!

I asked QA to run the full product suite and help expose anything that “felt off” after the changes.

Step 6: Fix → Test → Repeat

You fix what QA finds, QA tests again, you fix again… until the upgrade stops being “technically correct” and starts being “actually correct.”

After almost a week of doing this, I actually got the green light from everyone involved.

One and a half months after all these started and finally dropping down to my normal BPM, the cherry on top was a big presentation I made to everyone in the front end chapter and shared with the whole company, about the changed stuff and all the new, cool things we can build.

This was my first time diving so deep into a professional repository and I will never forget the experience.

The Takeaway: Cleanliness Is a Decision We Make Repeatedly

Doing this alone was a struggle, but it taught me a ton about the library, about our codebase, and about migration strategy. And it also made something obvious:

If we keep upgrades small and regular, they stay boring.
If we postpone them long enough, they become heroic.

Keeping the client modern is an ongoing struggle—but it’s also one of the most important forms of care we can give the product. Not because “new is shiny,” but because clean foundations make everything else easier.

So here’s my small, slightly dramatic plea:

Let’s not let garbage become architecture.
Let’s keep things up to date, consistently, incrementally, and without fear.