BXUP Track progress & test yourself — free
Frontend & Mobile · Playbook · Core · ~4 min read
FRONTEND & MOBILE · PLAYBOOK · CORE

Modernize Legacy React from Classes to Hooks with Test Pinning

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.

USE THIS WHEN

Pinning pays for itself

  • Class components with real lifecycle logic — componentDidUpdate diffing props, getDerivedStateFromProps, manual subscriptions.
  • HOC stacks (withRouter(connect(...))) you want to flatten to hooks.
  • The components are load-bearing and under-tested — precisely the combination that makes naive conversion dangerous.
  • A dependency upgrade (React 19, a router major) is forcing the issue.
SKIP IT WHEN

The ceremony outweighs the risk

  • Trivial class components — a render method and nothing else. Convert on sight; a characterization suite for a pure presenter is busywork.
  • The component is scheduled for deletion or full redesign — modernizing it is polishing a condemned building.
  • Behavior is currently wrong and you intend to fix it — do that as a separate, clearly-labeled change before or after, never during the mechanical conversion.

Prerequisites

  • React Testing Library + Vitest/Jest wired up and green.
  • A component inventory: grep -rl "extends React.Component\|extends Component" src/ | wc -l — know the size before promising a date.
  • Agreement that conversion PRs contain zero behavior changes. Write it in the PR template for this workstream.

The recipe

  1. 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.

  2. 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.

  3. 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.

  4. 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.

  5. 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.

  6. 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).

Warning

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.

Tool variations

  • Claude Code: run the whole loop per component — pin, convert, run suite, report — with the test command as its verification harness; review the diff and the test run, not just the final code.
  • Copilot / Cursor: solid for step-4 conversions with the trap-list prompt pasted as context; run the characterization suite yourself between each accept.
  • Redux-era codebases: convert component and container together per PR, or the pinned tests (which render the connected component) cannot run against both versions.
  • React Native: identical recipe; characterization tests use RTL for React Native, and the manual-subscription trap doubles in importance (AppState, Dimensions, keyboard listeners all live in lifecycles).

Quality check

  • Suite predates the diffthe characterization tests exist in a commit before the conversion commit, and passed against both versions.
  • Zero behavior deltano test was edited to make the conversion pass; discovered bugs were filed, not fixed inline.
  • Effect hygieneevery useEffect has an explained dependency array; no eslint-disable-line react-hooks/exhaustive-deps without a comment defending it.
  • Cleanup verifiedunmount tests cover every timer, listener, and subscription the class version tore down.
  • One component per PR for moderate/hairy conversions — a reviewer can hold the whole semantic story in their head.
Key Takeaway:

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.

This is the free reading view. Open BXUP to track your progress, run the hands-on labs, and prove it with original, exam-pattern assessments.
© Anvi Innovate · BXUP — Be Exponentially Upskilled · BXUP is an independent learning platform, not affiliated with or endorsed by AWS, Microsoft, Google, NVIDIA, IBM, Salesforce, Databricks, Oracle or Anthropic. All trademarks belong to their respective owners. · bxup.in