skip to content

You lead a large React codebase where most screens are exported through chains of legacy `withX` higher-order components. How do you plan the move to hooks without a freeze-and-rewrite?

level: principalimportance: nice to knowfreq 25%

answer

  1. do not freeze the codebase
  2. one implementation, two surfaces
  3. hook first, wrapper becomes a shim
  4. convert call sites leaf-first
  5. name the residue you keep

basics

~20 s

Move incrementally: implement each behaviour once as a hook, redefine the legacy wrapper as a thin shim over that hook so both call styles share one implementation, then convert call sites leaf-first while blocking new wrapper usage. Rank by debugging pain, and accept permanent residue.

solid answer

~60 s

Start by refusing the big-bang. The key move is to invert each wrapper: extract its logic into a hook, then redefine the HOC as three lines that call the hook and spread the result, so there is exactly one implementation and two surfaces. Nothing breaks, no call site changes, and the risky part — the logic — moves once under existing tests. After that the migration is a long tail of call-site conversions you can sequence by value: start with the wrappers that hurt most (the ones stacked three deep, the ones whose injected props collide, the ones people trip over while debugging incidents), and leave the harmless single wrappers alone. Add a guardrail so new code stops adding usages, prefer types and tests over codemods for verification since the injected props change shape, and pick a visible metric — wrapper depth on the top screens, or time to locate state in an incident. Finally, name the residue you will keep: wrappers around third-party components, gates that decide whether to render, error boundaries that must stay classes.

code

jsx · 23 lines
jsx
import { useSyncExternalStore } from 'react';
import { sessionStore } from './sessionStore';

// 1. the logic moves here, once
export function useCurrentUser() {
  return useSyncExternalStore(
    sessionStore.subscribe,
    sessionStore.getSnapshot,
    sessionStore.getServerSnapshot,
  );
}

// 2. the legacy wrapper becomes a shim over it, so old call sites keep working
export function withCurrentUser(Wrapped) {
  function WithCurrentUser(props) {
    const user = useCurrentUser();
    return <Wrapped {...props} user={user} />;
  }
  WithCurrentUser.displayName = `withCurrentUser(${
    Wrapped.displayName || Wrapped.name || 'Component'
  })`;
  return WithCurrentUser;
}

go deeper

for a junior

Know the safe first step: move the logic into a hook and let the old wrapper call that hook, so nothing at the call sites has to change yet.

for a middle

Be able to perform the conversion on one wrapper end to end — extract the hook, shim the wrapper, then update call sites and remove props the component no longer receives.

for a senior

Sequence it: inventory by call-site count and stacking depth, convert leaf-first in reviewable batches, and prove behaviour is unchanged for the awkward cases the wrapper was silently handling.

for a principal

Own the framing and the finish line — the cost argument that justifies the work, the guardrail that stops new usages, one published metric, and an explicit list of wrappers you are deliberately keeping forever.

## First, justify the work A migration with no cost argument gets abandoned halfway, which is worse than not starting: you end up maintaining both idioms with no end date. So state the cost in terms someone outside the team recognises. Usually it is one of: incident response is slow because finding which wrapper owns a piece of state takes minutes; onboarding is slow because a component's props have no visible origin; a class of silent bugs recurs because stacked wrappers collide on prop names. "Hooks are more modern" is not a cost argument, and a reviewer will say so. ## Inventory before you touch anything List every `withX` in the codebase with three facts: how many call sites, how deep it typically stacks, and what it injects. That inventory does most of the prioritisation for you. A wrapper used twice and never stacked is not worth a migration; a wrapper on 200 screens that always appears inside two others is the one paying for the project. Classify each one as: *pure logic* (computes and injects props — fully convertible), *renders something* (a provider, a layout element, a gate that may render a fallback — stays a component), or *third-party adapter* (wraps a component you do not control — usually stays). ## The bridge that makes it incremental The single most useful technique is to invert the implementation rather than replace it. Take the wrapper's logic out into a hook, then rewrite the wrapper in terms of the hook: ```jsx export function useCurrentUser() { // the logic that used to live inside withCurrentUser } export function withCurrentUser(Wrapped) { function WithCurrentUser(props) { const user = useCurrentUser(); return <Wrapped {...props} user={user} />; } WithCurrentUser.displayName = `withCurrentUser(${Wrapped.name || 'Component'})`; return WithCurrentUser; } ``` Now both surfaces exist, backed by one implementation. Existing call sites keep working untouched; new code imports the hook. The dangerous edit — moving the actual logic — happens once, in a small diff, covered by whatever tests already exercised the wrapper. Everything after that is mechanical and independently revertible. ## Sequencing Convert call sites leaf-first: change the component to call the hook, delete the wrapper from its export, adjust the props it no longer receives. Do it in batches that match review capacity, and prefer batches that fully retire one wrapper over batches that half-retire five, because a wrapper you can delete is progress you can see. Be wary of codemods here. The transformation is not purely syntactic: the injected prop may be renamed, may be optional, may be threaded further down to children, and may be typed loosely. A codemod that gets 90% right leaves a 10% tail that is harder to find than if you had done it by hand. Codemods are reasonable for the mechanical half (rewriting the export, removing the wrapper) with human review of the props. ## Guardrails Stop the bleeding first: agree that new code uses the hook, and enforce it however your repo enforces conventions — a lint restriction on importing the wrapper, a review checklist, or simply not exporting the wrapper from the package's public entry point. A migration that competes with ongoing creation of new usages never converges. Pick one metric and publish it. Wrapper depth on your ten busiest screens is concrete and moves visibly. Number of remaining call sites per wrapper works too. Avoid metrics you cannot move directly, like bundle size, which will be dominated by other factors. ## Know what you are keeping Declare the residue explicitly, or people will keep filing tickets about it: - Error boundaries remain classes, because catching render errors requires one. - Wrappers that render a provider, a layout element, or a fallback stay components — a hook cannot render. - Adapters around third-party components you cannot modify stay. - Anything with two call sites and no pain stays until it is touched for another reason. ## The failure modes to name Two migrations go wrong predictably. The first stalls at 60% and leaves both idioms permanently, because no one owned the tail — fix by scheduling the tail as work, not as opportunistic cleanup. The second breaks production because a wrapper was doing something undocumented — an implicit default prop, an ordering dependency between two wrappers, a side effect at module scope. That is why the bridge step matters: it lets you move the logic while the composition still holds, so surprises surface one at a time instead of all at once.

  • Halfway through, half the codebase uses the hook and half the wrapper. How do you keep that from becoming permanent?
    Treat the tail as scheduled work with an owner and a visible count, not as opportunistic cleanup, and close the intake so new usages cannot appear. Publish the remaining call sites per wrapper so progress is legible. If a wrapper genuinely cannot be retired, move it out of the migration list into the declared residue so the number can actually reach zero.
  • A wrapper turns out to inject a prop that some components rely on being absent when the user is logged out. How does that change your plan?
    It means the wrapper carried undocumented behaviour, so the conversion is a behavioural change, not a refactor. Make the contract explicit first — the hook returns a discriminated value rather than sometimes-undefined — then migrate call sites against the new contract with tests for the logged-out path. This is exactly the surprise the bridge step is designed to surface early and in isolation.
  • Would you use a codemod for the call-site conversions?
    Only for the mechanical part — rewriting the export and removing the wrapper from the composition chain. The props half is not purely syntactic: names differ, some props are threaded further down, some are optional. A codemod that is 90% right leaves a tail that is harder to find than manual conversion, so use it to prepare diffs that humans still review.

saying these in an interview costs you the question

  • Proposes a big-bang rewrite behind a feature freeze
  • Assumes a codemod can convert injected props safely
  • Plans to delete wrappers that render providers or fallbacks
  • Starts migrating before stopping new usages
  • Justifies the work as modernisation with no measured cost

context