Class-to-hooks conversion looks mechanical and is not: lifecycle semantics, this-bound closures, and derived-state hacks all change meaning under careless translation. Pin current behavior with characterization tests first, let codemods do the genuinely mechanical parts, and reserve the LLM for the ambiguous state semantics — one component at a time.
Tier: Core · Workflow area: Styling, responsive & i18n
The order of operations is the whole playbook. Tests before transformation is what separates modernization from rewriting-with-vibes.
componentDidUpdate diffing props, getDerivedStateFromProps, manual subscriptions.withRouter(connect(...))) you want to flatten to hooks.grep -rl "extends React.Component\|extends Component" src/ | wc -l — know the size before promising a date.Rank the inventory by risk, not by file order. Have the model triage:
Here is the list of class components (attached grep output with file
sizes). For each: classify as TRIVIAL (render-only or state-only),
MODERATE (uses lifecycles but no derived state or subscriptions), or
HAIRY (componentDidUpdate with prop diffing, getDerivedStateFromProps,
custom shouldComponentUpdate, manual event/store subscriptions, refs
passed to imperative APIs). Output a table: file | class | risk |
the specific hazard. Cite the line numbers for each hazard.
Spot-check the HAIRY rows. Convert trivial ones in batches; moderate and hairy ones get the full treatment below, one per PR.
Pin behavior with characterization tests — written against the unconverted component. This is the step teams skip and regret:
Write RTL characterization tests for SubscriptionPanel (class version,
attached). Do not test implementation; capture observable behavior:
what renders for each meaningful prop combination, what happens on
user interaction (use userEvent), what callbacks fire with what
arguments, and the update behavior — e.g. what changes when the
`planId` prop changes after mount. Cover the timer/subscription
cleanup on unmount. Aim for the 8-12 tests that would catch a botched
hooks conversion, not exhaustive coverage.
Run them against the class component until green and committed. These tests encode "what it does today," bugs included — resist fixing anything you discover; file it instead.
Run the mechanical codemods for the parts that are actually mechanical. npx react-codemod transforms handle the safe renames (rename-unsafe-lifecycles for UNSAFE_ prefixes, pure-component, and friends). Modern React majors ship their own codemods (npx codemod@latest react/19/...) for API migrations. What no codemod safely does is the class-to-hooks conversion itself for stateful components — that is the LLM lane, next.
Convert with the LLM, with the semantic traps named in the prompt. Generic "convert to hooks" prompts produce subtly wrong code. Name the traps:
Convert SubscriptionPanel to a function component with hooks. Rules:
- componentDidMount + componentDidUpdate(prevProps) diffing planId
becomes useEffect with [planId] — but preserve the current behavior
difference between mount and update if there is one (there is: the
analytics call fires only on update). Keep it, and comment why.
- The this.pollTimer subscription must be created and torn down in the
same useEffect; cleanup must handle the in-flight case.
- getDerivedStateFromProps here is the reset-on-prop-change hack:
replace with a `key`-based reset at the call site OR derive during
render — tell me which you chose and why.
- Do not add useCallback/useMemo except where a dependency array
requires referential stability. No behavior changes, no renames.
Review the diff for the classic tells: effects with missing or over-eager dependencies, this.state batching assumptions now split across multiple useState calls, and stale-closure bugs in callbacks captured by timers.
Judge with the pinned suite, then mutation-poke the scary parts. The characterization tests must pass unmodified. If a test fails and you believe the new behavior is "more correct" — stop; that is a behavior change smuggling itself in. Convert faithfully, land, then change behavior in a follow-up with its own test. For hairy components, hand-break one thing (flip an effect dependency) and confirm the suite catches it — a suite that catches nothing was decorative.
Flatten HOCs last, one layer at a time. connect → useSelector/useDispatch, withRouter → useNavigate/useParams, each as its own commit against the same pinned suite. HOC removal changes ref forwarding and display names — check anything that depended on either (test selectors, dev tooling, React.forwardRef consumers).
The most common conversion bug in the wild is componentDidUpdate logic moving into a useEffect that also runs on mount. The pinned suite only catches it if a test distinguishes mount from update — make sure step 2 included one; it is the test that pays for the whole exercise.
useEffect has an explained dependency array; no eslint-disable-line react-hooks/exhaustive-deps without a comment defending it.Codemods handle syntax; only tests handle semantics. A characterization suite written before conversion turns "did the LLM translate the lifecycle correctly?" from a code-review judgment call into a command you run — and it keeps paying rent as the regression net for the next refactor.