Migrating Class Components to Hooks: A Systematic Roadmap
AI generated
</>
{ }
React · Legacy Code · Refactoring · Migration
Migrating Class Components to Hooks
a systematic roadmap for legacy React

Turning a grown React codebase away from class components takes more than a find and replace pass. Ordering, lifecycle pitfalls, this.setState batching and tests decide whether the migration goes smoothly or introduces production bugs.

19 min read useState · useEffect · useContext · Testing React 18 · React 19

1. Why migrating class components to hooks is still relevant

Many teams believe that the question of how to migrate class components to hooks was settled with React 16.8 and no longer matters today. In practice things look different: codebases from 2018 to 2020 often contain hundreds of class components that were never touched simply because they worked. As soon as a new feature needs context, concurrent rendering or React 19 actions, the limits of the old class syntax show up, and the migration suddenly becomes urgent rather than optional.

The second reason teams still migrate class components to hooks today is the talent market. New developers learn React almost exclusively with hooks, often only know this bindings and lifecycle methods from documentation, and take twice as long to find a bug in a class component. A consistent migration therefore reduces not only technical debt, but also onboarding time and error rate across the team.

The third reason is more subtle: the React Compiler and many newer libraries such as TanStack Query or Zustand are built around function components and hooks. Whoever migrates class components to hooks only then truly opens up these tools, instead of awkwardly bolting them onto classes through wrapper components.

2. Inventory and order: which component goes first

Before a single line of code changes, the migration needs an inventory. A simple script counts every occurrence of extends React.Component or extends Component in the project and sorts them by size and number of lifecycle methods. Components with only render() and maybe a constructor() are the ideal starting point, because they migrate in minutes and give the team a quick sense of progress.

Complex class components with componentDidUpdate, multiple setState calls and internal timers should deliberately be migrated last, once the team already has some routine. A good rule of thumb: whoever plans to migrate class components to hooks should schedule a fixed number of components per sprint, instead of attempting one giant big bang refactor. A big bang approach massively increases the risk of regressions and blocks parallel feature work.

It is also important to identify components that are addressed from outside via refs, for example for imperative method calls. Those will later need forwardRef and useImperativeHandle, which increases the effort per component and must be accounted for in planning.


# Find all class components in a React codebase, sorted by lifecycle complexity
grep -rl "extends React.Component\|extends Component" src/ \
  | while read -r file; do
      lifecycles=$(grep -cE "componentDid|componentWill|shouldComponentUpdate" "$file")
      lines=$(wc -l < "$file")
      echo "$lifecycles $lines $file"
    done \
  | sort -rn > migration-inventory.txt

# Read the inventory: fewest lifecycle methods first (easy wins)
sort -n migration-inventory.txt | head -20

3. Migrating this.state to useState

The easiest part when you migrate class components to hooks is usually state itself. A this.state object with several fields is split into several independent useState calls, instead of keeping it as one large object. This has an important advantage over the class version: each field can be updated independently, without explicitly spreading the other fields, as this.setState({ ...this.state, field: value }) still required.

A common mistake during this migration: developers keep a single large state object with useState out of habit and then still have to manually spread it on every update. That throws away exactly the advantage useState offers over this.setState. Separate state per business concern, such as isLoading, error and data as three separate useState calls, makes the code more readable and the migration easier to review.


// BEFORE: class component with a combined state object
class UserProfile extends React.Component {
  constructor(props) {
    super(props);
    this.state = { user: null, isLoading: true, error: null };
  }

  componentDidMount() {
    fetchUser(this.props.userId)
      .then(user => this.setState({ user, isLoading: false }))
      .catch(error => this.setState({ error, isLoading: false }));
  }

  render() {
    const { user, isLoading, error } = this.state;
    if (isLoading) return <Spinner />;
    if (error) return <ErrorMessage error={error} />;
    return <ProfileCard user={user} />;
  }
}

// AFTER: function component with separate useState calls per concern
function UserProfile({ userId }) {
  const [user, setUser] = useState(null);
  const [isLoading, setIsLoading] = useState(true);
  const [error, setError] = useState(null);

  useEffect(() => {
    let cancelled = false;
    fetchUser(userId)
      .then(data => { if (!cancelled) { setUser(data); setIsLoading(false); } })
      .catch(err => { if (!cancelled) { setError(err); setIsLoading(false); } });
    return () => { cancelled = true; };
  }, [userId]);

  if (isLoading) return <Spinner />;
  if (error) return <ErrorMessage error={error} />;
  return <ProfileCard user={user} />;
}

4. Migrating lifecycle methods to useEffect

The hardest part when teams migrate class components to hooks is almost always merging componentDidMount, componentDidUpdate and componentWillUnmount into one or more useEffect calls. The three lifecycle methods cover different points in the lifecycle, whereas a single useEffect with a dependency array can cover all three cases, but only if the dependencies are specified correctly.

A typical mistake during this migration: a class component registers an event listener in componentDidMount and removes it in componentWillUnmount, but also uses logic in componentDidUpdate that must rerun on every prop change. When porting this to useEffect, developers frequently forget to return the cleanup function correctly, which leads to duplicate event listeners and memory leaks. The eslint-plugin-react-hooks package with its exhaustive-deps rule is mandatory here, because it catches exactly these missing dependencies.

It is also important to split effects by business purpose. Instead of writing one giant useEffect that handles mounting, updates and unmounting all at once, independent concerns should be split into separate useEffect calls. That matches the original intent of the hooks API and makes any class component migration considerably easier to follow.


// BEFORE: three lifecycle methods handling subscription and prop updates
class ChatRoom extends React.Component {
  componentDidMount() {
    this.connection = createConnection(this.props.roomId);
    this.connection.connect();
  }

  componentDidUpdate(prevProps) {
    if (prevProps.roomId !== this.props.roomId) {
      this.connection.disconnect();
      this.connection = createConnection(this.props.roomId);
      this.connection.connect();
    }
  }

  componentWillUnmount() {
    this.connection.disconnect();
  }

  render() {
    return <div>Connected to {this.props.roomId}</div>;
  }
}

// AFTER: a single useEffect with a correct dependency array replaces all three
function ChatRoom({ roomId }) {
  useEffect(() => {
    const connection = createConnection(roomId);
    connection.connect();
    return () => connection.disconnect(); // covers unmount AND roomId change
  }, [roomId]);

  return <div>Connected to {roomId}</div>;
}

5. setState batching: differences and pitfalls

A detail that is easily missed when migrating class components to hooks is the different batching behaviour. In class components, several this.setState calls inside a React event handler were combined into a single re render, but outside React events, for example in a setTimeout or a native DOM event, each call immediately triggered its own re render. Since React 18, useState also batches automatically outside React events, which is usually a performance win, but can change existing assumptions about re render ordering.

A second pitfall concerns functional updates. In class components, this.setState(prevState => ({ count: prevState.count + 1 })) was the recommended form when the new value depends on the old one. The same rule still applies with useState, but is frequently forgotten during the port, because developers mistakenly call setCount(count + 1) several times in a row and wonder why the counter only increases by one instead of several steps.


// WRONG: relies on the closure value of count, only increments once effectively
function handleClick() {
  setCount(count + 1);
  setCount(count + 1);
  setCount(count + 1);
} // count ends up +1, not +3

// RIGHT: functional update reads the latest pending state
function handleClick() {
  setCount(prev => prev + 1);
  setCount(prev => prev + 1);
  setCount(prev => prev + 1);
} // count ends up +3, matches the class component behaviour of this.setState(fn)

6. Migrating context Consumer and Provider to useContext

Class components that consumed context through <MyContext.Consumer> with a render prop become considerably simpler once ported to hooks. Instead of writing a nested render prop function, useContext(MyContext) reads the value directly and flatly inside the function body. This reduces nesting depth and makes the data flow visible at a glance, which makes a big difference especially with several nested contexts.

During this migration it is worth extracting a dedicated hook such as useAuth() at the same time, which internally calls useContext(AuthContext) and additionally checks whether the context is actually present. This prevents the common runtime error where a component is rendered outside the matching provider and receives undefined instead of the expected values.

7. Error boundaries: why they stay class components

An important point many teams overlook when planning to migrate class components to hooks: error boundaries cannot be fully expressed as a function component with hooks as of React 19. The methods static getDerivedStateFromError and componentDidCatch only exist on class components, because React internally relies on these lifecycle hooks to catch rendering errors in the parent hierarchy.

The pragmatic solution is to deliberately treat these few error boundary components as an exception to the migration and keep them as small, isolated class components in the project, ideally imported from a shared library such as react-error-boundary instead of hand rolled. That way the rest of the codebase stays consistently on function components, without the migration getting blocked at this one spot.

8. Tests as a safety net during migration

Without tests, any migration from class components to hooks becomes a blind flight. Before touching a component, there should be at least one test with React Testing Library that checks the visible behaviour from a user perspective, such as which texts render and how the component reacts to clicks. These tests remain valid after the migration, because Testing Library deliberately ignores implementation details like class versus function and only checks the rendered result.

Snapshot tests deserve caution during this migration, because a component's internal structure does not change through the port to hooks, but loading states or effect ordering sometimes do. A snapshot that stubbornly checks for character identity then produces false alarms that have nothing to do with an actual bug. Behaviour based tests with explicit expectations are the far more robust choice for this migration.

9. Codemods and automation compared

For large codebases it is worth asking which steps in the port from class components to hooks can be automated and which need manual care. The table below compares the common approaches.

Migration step Manual Automated Recommendation
this.state to useState Time consuming but safe react-codemod partially usable Manual with review
Lifecycle to useEffect Requires domain understanding Codemods often fail on ordering Always manual
PropTypes to TypeScript Tedious without tools proptypes-to-typescript codemod Codemod as starting point
Missing exhaustive deps Easy to overlook eslint-plugin-react-hooks Automated via linter
Cleaning up imports Tedious across many files jscodeshift script Automated via codemod

The table shows a clear pattern: mechanical, syntactic changes can be automated well, while behavioural changes, especially around lifecycle methods, require domain understanding and manual care. Whoever migrates class components to hooks and tries to hand this part entirely to a codemod usually ends up producing more bugs than it saves.

Mironsoft

React migrations, legacy refactoring and modern frontend architecture

Need to safely move old class components to hooks?

We analyze your React codebase, prioritize the migration by risk, and support the port from class components to hooks with tests, codemods and code reviews.

Codebase Audit

Inventory of all class components with risk and effort estimation

Guided Migration

Stepwise port with test coverage instead of a risky big bang

Team Training

Hooks workshops for teams still working with class components

10. Summary

Whoever wants to migrate class components to hooks should first build an inventory and prioritize by complexity, instead of aimlessly opening the next best file. Simple components with little state and no lifecycle methods are the ideal starting point. this.state gets split into separate useState calls per business concern, while componentDidMount, componentDidUpdate and componentWillUnmount get merged into a carefully configured useEffect with a correct dependency array.

Batching differences between this.setState and useState, context migration to useContext, and the deliberate exception for error boundaries round out the technical side. Tests with React Testing Library are the safety net that makes any migration from class components to hooks risk free in the first place, while codemods can only sensibly automate the mechanical, not the behavioural, parts of the migration.

Migrating Class Components to Hooks: The Essentials

Ordering

Simple components without lifecycle methods first, complex ones with multiple setState calls last.

Lifecycle to useEffect

Merge componentDidMount, componentDidUpdate and componentWillUnmount into one useEffect with a correct dependency array.

Watch batching

Functional updates like setCount(prev => prev + 1) prevent lost state updates.

Error boundary exception

componentDidCatch only exists on classes, deliberately leave these few components unmigrated.

11. FAQ: Migrating Class Components to Hooks

1Do I have to migrate everything?
No, React still supports classes. Migration pays off where new features or maintainability are needed. Error boundaries stay classes anyway.
2How do I prioritize the order?
An inventory script counts lifecycle methods per component. Simple ones first, complex ones last.
3Most common lifecycle mistake?
Missing dependencies in the useEffect array. eslint-plugin-react-hooks with exhaustive-deps catches this automatically.
4Why does setState behave differently?
React 18 batches useState outside events too. Functional updates with prev => behave as before.
5Error boundaries as function components?
Not fully possible, componentDidCatch only exists on classes. react-error-boundary encapsulates this exception.
6Snapshot tests before migration?
Better to use behaviour based tests with Testing Library, which stay valid after the migration.
7Are there codemods for this?
Yes for mechanical steps, but not really for lifecycle to useEffect, which needs manual care.
8How long does a mid sized migration take?
With fixed sprint capacity, 100 to 200 components are realistic in two to three months.
9What happens to this.props?
Becomes normal function parameters, usually destructured directly in the function component signature.
10Worth migrating before replacement?
Usually not, if the codebase will be replaced within months, effort should go into the new solution instead.