How to Migrate React Class Components to Functional Components with Hooks

Type: Software Reference Confidence: 0.93 Sources: 8 Verified: 2026-02-23 Freshness: monthly

TL;DR

Constraints

Quick Reference

Class PatternHook EquivalentExample
this.state = { count: 0 }useState(0)const [count, setCount] = useState(0)
this.setState({ count: 1 })setCount(1)setCount(prev => prev + 1) for batched updates
this.setState(prev => ...)Updater functionsetCount(prev => prev + 1)
componentDidMountuseEffect(fn, [])useEffect(() => { fetchData() }, [])
componentDidUpdate(prevProps)useEffect(fn, [deps])useEffect(() => { sync() }, [userId])
componentWillUnmountCleanup returnuseEffect(() => { return () => cleanup() }, [])
shouldComponentUpdateReact.memo()export default React.memo(MyComponent)
this.context (contextType)useContext(Ctx)const theme = useContext(ThemeContext)
createRef()useRef(null)const inputRef = useRef(null)
this.refs.myInputuseRefinputRef.current.focus()
getDerivedStateFromPropsCompute during renderif (props.id !== prevId) { setPrevId(props.id) }
forceUpdate()useReducer trickconst [, forceUpdate] = useReducer(x => x + 1, 0)
defaultPropsDefault parametersfunction Btn({ color = 'blue' }) {}
componentDidCatch / Error Boundaryreact-error-boundary<ErrorBoundary fallback={<Err />}>
Instance variables (this.timer)useRefconst timerRef = useRef(null)
forwardRef (React 18)Direct ref prop (React 19)function Input({ ref }) { ... }

Decision Tree

START
+-- Is it an Error Boundary (componentDidCatch / getDerivedStateFromError)?
|   +-- YES -> Keep as class OR use react-error-boundary package
|   +-- NO |
+-- Does it use getSnapshotBeforeUpdate?
|   +-- YES -> Keep as class (no hook equivalent yet)
|   +-- NO |
+-- Does it use only state + simple lifecycle?
|   +-- YES -> Convert: useState + useEffect (see Step-by-Step below)
|   +-- NO |
+-- Does it use context?
|   +-- YES -> Add useContext, remove static contextType
|   +-- NO |
+-- Does it use refs (createRef / string refs)?
|   +-- YES -> Convert to useRef
|   +-- NO |
+-- Does it use getDerivedStateFromProps?
|   +-- YES -> Compute during render (see Quick Reference)
|   +-- NO |
+-- Does it use shouldComponentUpdate?
|   +-- YES -> Wrap with React.memo() + useMemo for expensive computations
|   +-- NO |
+-- Does it use forwardRef? (React 19)
|   +-- YES -> Remove forwardRef wrapper, accept ref as a regular prop
|   +-- NO |
+-- DEFAULT -> Convert to function + hooks

Step-by-Step Guide

1. Install ESLint hooks plugin

Add the ESLint plugin to catch hook rule violations during migration. This prevents the most common mistakes (conditional hooks, missing dependencies). React 19's ESLint plugin includes flat config by default and opt-in React Compiler-powered rules. [src4, src8]

npm install --save-dev eslint-plugin-react-hooks

Verify: npx eslint src/ --ext .jsx,.tsx runs without plugin errors.

2. Convert class declaration to function

Remove the class wrapper, constructor, and render method. Move the JSX return to the function body. [src1, src5]

// BEFORE: Class component
class Counter extends React.Component {
  constructor(props) {
    super(props);
    this.state = { count: 0 };
    this.handleClick = this.handleClick.bind(this);
  }
  handleClick() {
    this.setState({ count: this.state.count + 1 });
  }
  render() {
    return (
      <div>
        <p>{this.state.count}</p>
        <button onClick={this.handleClick}>+1</button>
      </div>
    );
  }
}

// AFTER: Function component
function Counter() {
  const [count, setCount] = useState(0);
  function handleClick() {
    setCount(prev => prev + 1);
  }
  return (
    <div>
      <p>{count}</p>
      <button onClick={handleClick}>+1</button>
    </div>
  );
}

Verify: Component renders identically. No this keyword remains in the component.

3. Replace this.state and this.setState with useState

Each piece of state becomes its own useState call. Prefer multiple useState calls over a single state object for independent values. [src1, src6]

// BEFORE: Single state object
class Form extends React.Component {
  state = { name: '', email: '', age: 0 };
  handleChange = (field, value) => {
    this.setState({ [field]: value });
  };
}

// AFTER: Individual useState calls
function Form() {
  const [name, setName] = useState('');
  const [email, setEmail] = useState('');
  const [age, setAge] = useState(0);
}

// OR: useReducer for complex related state
function Form() {
  const [state, dispatch] = useReducer(formReducer, {
    name: '', email: '', age: 0
  });
}

Verify: All state transitions work. Check that setState callbacks (second arg) are replaced with useEffect watching the state variable.

4. Replace lifecycle methods with useEffect

Map each lifecycle method to the correct useEffect pattern. Think in terms of "synchronization with dependencies" rather than "moments in the component lifecycle." [src2, src3]

// BEFORE: Class lifecycle
class ChatRoom extends React.Component {
  componentDidMount() {
    this.connect(this.props.roomId);
  }
  componentDidUpdate(prevProps) {
    if (prevProps.roomId !== this.props.roomId) {
      this.disconnect(prevProps.roomId);
      this.connect(this.props.roomId);
    }
  }
  componentWillUnmount() {
    this.disconnect(this.props.roomId);
  }
}

// AFTER: Single useEffect with cleanup
function ChatRoom({ roomId }) {
  useEffect(() => {
    const connection = connect(roomId);
    return () => connection.disconnect();
  }, [roomId]);  // Re-runs when roomId changes
}

Verify: Effect fires on mount, re-fires when dependencies change, and cleans up on unmount.

5. Replace this.context with useContext

Remove static contextType and use the useContext hook. In React 19, you can also use the use() API to read context. [src1, src8]

// BEFORE
class ThemedButton extends React.Component {
  static contextType = ThemeContext;
  render() {
    return <button className={`btn-${this.context}`}>{this.props.label}</button>;
  }
}

// AFTER: useContext hook
function ThemedButton({ label }) {
  const theme = useContext(ThemeContext);
  return <button className={`btn-${theme}`}>{label}</button>;
}

// REACT 19 ALTERNATIVE: use() API
function ThemedButton({ label }) {
  const theme = use(ThemeContext);
  return <button className={`btn-${theme}`}>{label}</button>;
}

Verify: Context values propagate correctly. Test by changing the provider value.

6. Replace createRef with useRef

Convert React.createRef() to useRef(). Also convert instance variables (timers, flags) to refs. In React 19, forwardRef is no longer needed — ref is passed as a regular prop. [src1, src4, src8]

// BEFORE
class TextInput extends React.Component {
  inputRef = React.createRef();
  debounceTimer = null;
  componentWillUnmount() {
    clearTimeout(this.debounceTimer);
  }
}

// AFTER
function TextInput() {
  const inputRef = useRef(null);
  const debounceTimer = useRef(null);
  useEffect(() => {
    return () => clearTimeout(debounceTimer.current);
  }, []);
}

// React 19: ref as a regular prop (no forwardRef needed)
function TextInput({ ref }) {
  return <input ref={ref} />;
}

Verify: ref.current points to the DOM element after mount. Instance variable refs do not cause re-renders when mutated.

7. Replace shouldComponentUpdate with React.memo

Wrap the component with React.memo() for shallow prop comparison. Use useMemo for expensive computations within the component. In React 19 with the React Compiler, auto-memoization may make manual memo unnecessary. [src1, src8]

// BEFORE
class ExpensiveList extends React.Component {
  shouldComponentUpdate(nextProps) {
    return nextProps.items !== this.props.items;
  }
  render() {
    return <ul>{this.props.items.map(i => <li key={i.id}>{i.name}</li>)}</ul>;
  }
}

// AFTER
const ExpensiveList = React.memo(function ExpensiveList({ items }) {
  return <ul>{items.map(i => <li key={i.id}>{i.name}</li>)}</ul>;
});

Verify: Component skips re-render when props are unchanged (check React DevTools highlight updates).

Code Examples

JavaScript/React: Full class-to-hooks conversion (data fetching component)

// Input:  A class component that fetches user data on mount/update with loading & error states
// Output: Equivalent functional component using useState + useEffect

import { useState, useEffect } from 'react';

function UserProfile({ userId }) {
  const [user, setUser] = useState(null);
  const [loading, setLoading] = useState(true);
  const [error, setError] = useState(null);

  useEffect(() => {
    const controller = new AbortController();
    setLoading(true);
    setError(null);

    fetch(`/api/users/${userId}`, { signal: controller.signal })
      .then(res => {
        if (!res.ok) throw new Error(`HTTP ${res.status}`);
        return res.json();
      })
      .then(data => setUser(data))
      .catch(err => {
        if (err.name !== 'AbortError') setError(err.message);
      })
      .finally(() => setLoading(false));

    return () => controller.abort();
  }, [userId]);

  if (loading) return <p>Loading...</p>;
  if (error) return <p>Error: {error}</p>;
  return <h1>{user?.name}</h1>;
}

export default UserProfile;

TypeScript/React: Converting a complex class with multiple lifecycles

// Input:  A class component with timer, event listener, and derived state
// Output: Equivalent functional component with multiple useEffect calls

import { useState, useEffect, useRef } from 'react';

interface Props {
  query: string;
  debounceMs?: number;
}

function DebouncedSearch({ query, debounceMs = 300 }: Props) {
  const [results, setResults] = useState<string[]>([]);
  const [debouncedQuery, setDebouncedQuery] = useState(query);
  const [isOnline, setIsOnline] = useState(navigator.onLine);
  const abortRef = useRef<AbortController | null>(null);

  // Effect 1: Debounce the query
  useEffect(() => {
    const timer = setTimeout(() => setDebouncedQuery(query), debounceMs);
    return () => clearTimeout(timer);
  }, [query, debounceMs]);

  // Effect 2: Fetch results when debounced query changes
  useEffect(() => {
    if (!debouncedQuery) { setResults([]); return; }
    abortRef.current?.abort();
    abortRef.current = new AbortController();
    fetch(`/api/search?q=${encodeURIComponent(debouncedQuery)}`, {
      signal: abortRef.current.signal,
    })
      .then(res => res.json())
      .then((data: { items: string[] }) => setResults(data.items))
      .catch(err => { if (err.name !== 'AbortError') console.error(err); });
    return () => abortRef.current?.abort();
  }, [debouncedQuery]);

  // Effect 3: Online/offline listener
  useEffect(() => {
    const handleOnline = () => setIsOnline(true);
    const handleOffline = () => setIsOnline(false);
    window.addEventListener('online', handleOnline);
    window.addEventListener('offline', handleOffline);
    return () => {
      window.removeEventListener('online', handleOnline);
      window.removeEventListener('offline', handleOffline);
    };
  }, []);

  return (
    <div>
      {!isOnline && <p>You are offline</p>}
      <ul>{results.map((r, i) => <li key={i}>{r}</li>)}</ul>
    </div>
  );
}

JavaScript/React: Extracting class logic into custom hooks

// Input:  Reusable logic scattered across class lifecycle methods
// Output: Custom hooks that encapsulate the same logic for any component

import { useState, useEffect, useRef } from 'react';

// Custom hook: replaces class mixin for window resize tracking
function useWindowSize() {
  const [size, setSize] = useState({
    width: window.innerWidth,
    height: window.innerHeight,
  });
  useEffect(() => {
    const handleResize = () => {
      setSize({ width: window.innerWidth, height: window.innerHeight });
    };
    window.addEventListener('resize', handleResize);
    return () => window.removeEventListener('resize', handleResize);
  }, []);
  return size;
}

// Custom hook: replaces class interval pattern
function useInterval(callback, delay) {
  const savedCallback = useRef(callback);
  useEffect(() => { savedCallback.current = callback; }, [callback]);
  useEffect(() => {
    if (delay === null) return;
    const id = setInterval(() => savedCallback.current(), delay);
    return () => clearInterval(id);
  }, [delay]);
}

// Usage — replaces a class component that combined resize + interval
function Dashboard() {
  const { width } = useWindowSize();
  const [data, setData] = useState(null);
  useInterval(() => {
    fetch('/api/dashboard').then(r => r.json()).then(setData);
  }, 30000);
  return (
    <div>
      <p>Window width: {width}</p>
      {data && <pre>{JSON.stringify(data, null, 2)}</pre>}
    </div>
  );
}

JavaScript/React: React 19 — new hooks replacing class patterns

// Input:  Class component with form submission and optimistic UI
// Output: React 19 functional component using useActionState + useOptimistic
import { useActionState, useOptimistic } from 'react';

function CommentForm({ existingComments }) {
  const [optimisticComments, addOptimistic] = useOptimistic(
    existingComments,
    (state, newComment) => [...state, { ...newComment, pending: true }]
  );

  const [state, submitAction, isPending] = useActionState(
    async (prevState, formData) => {
      const text = formData.get('comment');
      addOptimistic({ text, id: Date.now() });
      const result = await postComment(text);
      return { comments: [...prevState.comments, result] };
    },
    { comments: existingComments }
  );

  return (
    <form action={submitAction}>
      <input name="comment" disabled={isPending} />
      <button type="submit" disabled={isPending}>Post</button>
    </form>
  );
}

Anti-Patterns

Wrong: Treating useEffect(fn, []) as identical to componentDidMount

// ❌ BAD — Missing dependency causes stale data
function UserProfile({ userId }) {
  const [user, setUser] = useState(null);
  useEffect(() => {
    fetch(`/api/users/${userId}`)
      .then(r => r.json())
      .then(setUser);
  }, []);  // userId is used but not in deps
}

Correct: Include all dependencies in the array

// ✅ GOOD — Re-fetches when userId changes, with cleanup
function UserProfile({ userId }) {
  const [user, setUser] = useState(null);
  useEffect(() => {
    const controller = new AbortController();
    fetch(`/api/users/${userId}`, { signal: controller.signal })
      .then(r => r.json())
      .then(setUser)
      .catch(err => { if (err.name !== 'AbortError') console.error(err); });
    return () => controller.abort();
  }, [userId]);
}

Wrong: Calling hooks conditionally

// ❌ BAD — Hook called after early return breaks Rules of Hooks
function GameDetails({ id }) {
  if (!id) return <p>Select a game</p>;
  const [game, setGame] = useState(null);  // Order changes between renders!
  useEffect(() => { /* fetch */ }, [id]);
}

Correct: Always call hooks at the top level

// ✅ GOOD — All hooks called unconditionally
function GameDetails({ id }) {
  const [game, setGame] = useState(null);
  useEffect(() => {
    if (!id) return;
    fetch(`/api/games/${id}`).then(r => r.json()).then(setGame);
  }, [id]);
  if (!id) return <p>Select a game</p>;
  return <div>{game?.title}</div>;
}

Wrong: Using stale state in callbacks

// ❌ BAD — count captured at 0, clicking 3x fast only increments to 1
function Counter() {
  const [count, setCount] = useState(0);
  const increment = useCallback(() => {
    setCount(count + 1);  // Stale closure
  }, []);
}

Correct: Use functional updater to read latest state

// ✅ GOOD — Functional updater always reads the latest state
function Counter() {
  const [count, setCount] = useState(0);
  const increment = useCallback(() => {
    setCount(prev => prev + 1);  // prev is always current
  }, []);
}

Wrong: Storing timer IDs in state

// ❌ BAD — Storing infrastructure data in state triggers re-render
function Poller() {
  const [timerId, setTimerId] = useState(null);
  useEffect(() => {
    const id = setInterval(() => { /* poll */ }, 5000);
    setTimerId(id);  // Unnecessary re-render!
    return () => clearInterval(id);
  }, []);
}

Correct: Use useRef for infrastructure data

// ✅ GOOD — useRef stores timer ID without triggering re-render
function Poller() {
  const timerRef = useRef(null);
  useEffect(() => {
    timerRef.current = setInterval(() => { /* poll */ }, 5000);
    return () => clearInterval(timerRef.current);
  }, []);
}

Wrong: Forgetting cleanup in useEffect

// ❌ BAD — Event listener persists after unmount
function MouseTracker() {
  const [pos, setPos] = useState({ x: 0, y: 0 });
  useEffect(() => {
    const handler = (e) => setPos({ x: e.clientX, y: e.clientY });
    window.addEventListener('mousemove', handler);
    // No cleanup!
  }, []);
}

Correct: Always return cleanup for subscriptions

// ✅ GOOD — Cleanup removes listener on unmount
function MouseTracker() {
  const [pos, setPos] = useState({ x: 0, y: 0 });
  useEffect(() => {
    const handler = (e) => setPos({ x: e.clientX, y: e.clientY });
    window.addEventListener('mousemove', handler);
    return () => window.removeEventListener('mousemove', handler);
  }, []);
}

Wrong: Using useRef without initializer in React 19 TypeScript

// ❌ BAD — React 19 TypeScript requires initializer for useRef
function MyComponent() {
  const myRef = useRef<string>();  // TypeScript error in React 19!
}

Correct: Always provide initializer to useRef in React 19

// ✅ GOOD — Explicit undefined initializer satisfies React 19 types
function MyComponent() {
  const myRef = useRef<string>(undefined);  // Required in React 19 TypeScript
}

Common Pitfalls

Diagnostic Commands

# Find all class components in the codebase
grep -rn 'extends React\.Component\|extends Component' --include='*.js' --include='*.jsx' --include='*.tsx' | wc -l

# Find deprecated lifecycle methods that need migration
grep -rn 'componentWillMount\|componentWillReceiveProps\|componentWillUpdate' --include='*.js' --include='*.jsx' --include='*.tsx'

# Run React codemod to rename unsafe lifecycles (UNSAFE_ prefix)
npx react-codemod rename-unsafe-lifecycles src/

# Check for hook rule violations
npx eslint src/ --rule '{"react-hooks/rules-of-hooks": "error", "react-hooks/exhaustive-deps": "warn"}'

# Count remaining class vs function components
echo "Classes:" && grep -rn 'extends Component' --include='*.tsx' --include='*.jsx' | wc -l
echo "Functions:" && grep -rn '^function\|^const.*=.*=>' --include='*.tsx' --include='*.jsx' | wc -l

# Check for this.setState usage (indicates unconverted code)
grep -rn 'this\.setState' --include='*.js' --include='*.jsx' --include='*.tsx' | wc -l

# Find forwardRef usage (can be removed in React 19)
grep -rn 'forwardRef' --include='*.js' --include='*.jsx' --include='*.tsx' | wc -l

Version History & Compatibility

VersionStatusBreaking ChangesMigration Notes
React 19.2 (2025-10)CurrentBug fixes, perf improvementsLatest stable; React Compiler generally available
React 19 (2024-12)CurrentforwardRef removed (ref is a regular prop), use() API, useActionState/useOptimistic/useFormStatus hooksClass components still supported; ref can be passed directly as prop; auto-memoization via Compiler
React 18 (2022)LTSReactDOM.render deprecated, Strict Mode double-invokes effectsReplace ReactDOM.render() with createRoot().render(); effects must be idempotent
React 17 (2020)MaintenanceNew JSX transform, event delegation on rootNo import React needed in JSX files with new transform
React 16.8 (2019)EOLHooks introducedMinimum version for hooks; class components work in all versions

When to Use / When Not to Use

Use WhenDon't Use WhenUse Instead
Writing new components in an existing codebaseComponent uses getSnapshotBeforeUpdateKeep as class component
Component has tangled lifecycle logic (mount + update + unmount)Component is a working Error BoundaryKeep class or use react-error-boundary
You need to extract reusable stateful logic (custom hooks)Class component is stable, well-tested, and won't changeLeave it alone
Team is adopting TypeScript (hooks have simpler type signatures)You're on React < 16.8 and can't upgradeUpgrade React first
Component logic is duplicated via HOCs/render propsMigration deadline is tight and tests are insufficientAdd tests first, then migrate
Adopting React 19 features (Actions, use(), Compiler)Class component uses patterns with no hook equivalentKeep as class until equivalent exists

Important Caveats

Related Units