The Migration That Was Always Almost Done

A client's React class-to-hooks migration had been "90% complete" for fourteen months. The last 10% contained every hard problem the team had been avoiding. Gradual migrations don't fail at the start — they fail when nobody decides to finish.


The Jira epic said "React Class Components → Hooks Migration." It was created in March 2025. When I joined the project in May 2026, it was marked 90% complete.

It had been 90% complete since the previous July.

The easy part went fast

The migration started well. Two senior engineers ran a codemod that converted 140 of the simpler class components in a single afternoon. State-only components, lifecycle methods that mapped cleanly to useEffect — the mechanical stuff. Over the next three weeks, the team picked off another 30 components that needed minor refactoring. The PR reviews were quick, the tests passed, and management was thrilled with the burn-down chart.

Then it stopped.

The remaining 18 components were different. Not because they were complicated in ways anyone had anticipated, but because each one was complicated in its own specific way.

The taxonomy of leftovers

Component number one was a 1,400-line form wizard that used componentDidUpdate with five different conditional branches comparing previous props to current props. It had been written by someone who left the company two years ago. The tests for it were integration tests that took 45 seconds each and tested the entire checkout flow.

Component number two was a data grid that communicated with three sibling components through a shared ref chain. Converting it to hooks meant rethinking the data flow between four components simultaneously.

Component number three used getSnapshotBeforeUpdate — a lifecycle method so rarely used that the engineer assigned to migrate it had to look up what it did. The component measured scroll positions before DOM updates and adjusted them afterward to prevent content jumps during infinite scroll. The hooks equivalent required useRef with a useLayoutEffect, but the timing was subtly different, and the scroll position flickered on slower devices.

I could go on. Every remaining component had a story like this.

Why the last 10% is different

Gradual migrations follow a predictable pattern. I've seen it with database migrations, framework upgrades, language transitions, API versioning — the shape is always the same.

The first 60% is mechanical. You write a script, or you establish a pattern, and a motivated engineer can knock out several conversions per day. The work is satisfying and visible. Stakeholders see progress.

The next 25% requires thought. Each item needs individual attention, maybe some refactoring of surrounding code. The pace slows, but it's still moving. Still feels like progress.

The last 15% is where migrations go to die. Each remaining item is a miniature project unto itself. The reason it wasn't converted in the first two passes is precisely because it's hard, or tangled, or poorly understood, or all three. The codemod didn't touch it for a reason.

And by this point, the team's energy is spent. The exciting part of the migration is over. The remaining work is unglamorous slog through code nobody wants to touch, with no visible impact on the product. Feature work keeps piling up. Sprint planning starts treating the migration as "we'll get to it when we have capacity."

Capacity never arrives.

The hidden cost of "almost done"

What the team didn't realize — and what I had to spell out in a deck that nobody enjoyed reading — was that the half-finished migration was actively making things worse.

New engineers joining the team had to learn two patterns. Every code review involved the question: "Should this be a hook or a class component?" The answer was always hooks, but the 18 remaining class components kept pulling people back into the old style. I watched a junior engineer spend half a day reading the React docs on componentDidUpdate because they needed to fix a bug in one of the unconverted components.

The linting rules were a mess. The team had added ESLint rules to enforce hooks in new code, but had to suppress them in 18 files. Someone had written a custom lint rule to distinguish "legacy class components pending migration" from "new class components that shouldn't exist," but it had three false positives and nobody maintained it.

The migration branch strategy had also ossified. The team kept a tracking document that listed which components were "safe to modify" and which were "waiting for migration." Twice, engineers built features on top of class components that were supposed to be migrated, creating new dependencies on the old pattern.

Note

The cost of a half-finished migration isn't the remaining work — it's the drag it creates on everything else while it sits there.

How we finished it

There was no clever trick. We timebox'd it. I convinced the engineering manager to dedicate two engineers for three weeks with one rule: no feature work until the migration is done. Not 90% done. Done done.

The first week was painful. The form wizard took four days by itself. The engineer who tackled it ended up splitting it into three components, rewriting the state management with useReducer, and — crucially — writing new tests rather than trying to preserve the old integration tests that tested too much.

The data grid with the ref chain needed a small architecture change. We introduced a context provider to replace the sibling-to-sibling ref communication. The diff was larger than anyone wanted, but the resulting code was actually simpler than the original.

The infinite scroll component took two attempts. The first version used useLayoutEffect and worked on Chrome but flickered on Firefox. The second version used requestAnimationFrame inside the effect cleanup. Not pretty, but it matched the original behavior.

By the end of week two, fourteen of the eighteen components were done. The remaining four took the full third week — not because they were individually harder, but because each one required updating consumers and verifying behavior in contexts nobody had documented.

On the last day, the engineer deleted the custom ESLint rule, the tracking document, and the "legacy components" section from the onboarding guide. That felt better than any of the actual conversions.

The pattern repeats

Since that engagement, I've seen the same dynamic three more times. A TypeScript strict: true migration sitting at 85% for eight months. A REST-to-GraphQL conversion where twelve endpoints never made the jump. A monolith-to-services extraction where two domains were still deployed as part of the old application eighteen months after the "migration" was declared complete.

The fix is always the same, and it's never technical. Someone has to decide that finishing is more important than starting the next thing. That's an organizational decision, not an engineering one, and it's the one nobody wants to make because "almost done" feels close enough.

It never is. The 10% you're avoiding is the 10% that keeps generating friction for everyone, every day, until someone finally sits down and grinds through it. Or until you admit you're never going to finish and rip out the migration tracking instead — which is also a valid choice, just one that requires honesty nobody wants to volunteer.

What's the oldest "almost done" migration in your codebase?