this.state/this.setState to useState, lifecycle methods (componentDidMount, componentDidUpdate, componentWillUnmount) to useEffect with dependency arrays, and this.context to useContext. Error boundaries remain class-only unless you use react-error-boundary. React 19's Compiler auto-memoizes, reducing the need for manual useMemo/useCallback.npx react-codemod rename-unsafe-lifecycles + eslint-plugin-react-hooksuseEffect(fn, []) as identical to componentDidMount — it fires after paint (not synchronously), captures stale closures, and requires dependencies that componentDidMount never needed.eslint-plugin-react-hooks)componentDidCatch / getDerivedStateFromError) — use react-error-boundary as the hook-based alternativegetSnapshotBeforeUpdate has no hook equivalent — keep those components as classesuseEffect fires asynchronously after paint — use useLayoutEffect for synchronous DOM measurements before repaintuseMemo/useCallback may be unnecessary in new projects using the compiler| Class Pattern | Hook Equivalent | Example |
|---|---|---|
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 function | setCount(prev => prev + 1) |
componentDidMount | useEffect(fn, []) | useEffect(() => { fetchData() }, []) |
componentDidUpdate(prevProps) | useEffect(fn, [deps]) | useEffect(() => { sync() }, [userId]) |
componentWillUnmount | Cleanup return | useEffect(() => { return () => cleanup() }, []) |
shouldComponentUpdate | React.memo() | export default React.memo(MyComponent) |
this.context (contextType) | useContext(Ctx) | const theme = useContext(ThemeContext) |
createRef() | useRef(null) | const inputRef = useRef(null) |
this.refs.myInput | useRef | inputRef.current.focus() |
getDerivedStateFromProps | Compute during render | if (props.id !== prevId) { setPrevId(props.id) } |
forceUpdate() | useReducer trick | const [, forceUpdate] = useReducer(x => x + 1, 0) |
defaultProps | Default parameters | function Btn({ color = 'blue' }) {} |
componentDidCatch / Error Boundary | react-error-boundary | <ErrorBoundary fallback={<Err />}> |
Instance variables (this.timer) | useRef | const timerRef = useRef(null) |
forwardRef (React 18) | Direct ref prop (React 19) | function Input({ ref }) { ... } |
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
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.
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.
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.
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.
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.
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.
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).
// 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;
// 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>
);
}
// 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>
);
}
// 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>
);
}
// ❌ 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
}
// ✅ 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]);
}
// ❌ 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]);
}
// ✅ 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>;
}
// ❌ 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
}, []);
}
// ✅ 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
}, []);
}
// ❌ 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);
}, []);
}
// ✅ 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);
}, []);
}
// ❌ 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!
}, []);
}
// ✅ 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);
}, []);
}
// ❌ BAD — React 19 TypeScript requires initializer for useRef
function MyComponent() {
const myRef = useRef<string>(); // TypeScript error in React 19!
}
// ✅ GOOD — Explicit undefined initializer satisfies React 19 types
function MyComponent() {
const myRef = useRef<string>(undefined); // Required in React 19 TypeScript
}
useEffect(fn, []) captures variables from the initial render. If you reference props or state inside the effect but omit them from the dependency array, you get stale values. Fix: Always include every variable used inside the effect in the dependency array, or use functional updaters. [src3]componentDidMount which runs before the browser paints, useEffect runs after paint. This causes visible flicker for synchronous DOM measurements. Fix: Use useLayoutEffect when you need to measure/mutate the DOM before the browser repaints. [src3]useMemo/useCallback. With React 19 Compiler, auto-memoization handles many of these cases. [src4, src8]this.setState which merges state, useState setter replaces the entire value. Fix: Use the spread operator: setState(prev => ({ ...prev, name: 'new' })), or use useReducer for complex related state. [src1]this.setState({}, callback) runs callback after state update. Hooks have no equivalent. Fix: Use useEffect watching the state variable to run code after it changes. [src6]useEffect(async () => ...) returns a Promise instead of a cleanup function. Fix: Define the async function inside the effect and call it: useEffect(() => { async function fetch() {...} fetch(); }, []). [src7]shouldComponentUpdate receives both props and state; React.memo only compares props. Fix: Restructure the component to lift state up or use useMemo for expensive derived computations. [src1]this.timer, this.isMounted) disappear in function components. Using useState for these causes unnecessary re-renders. Fix: Use useRef for any mutable value that should not trigger a re-render. [src4]shallow() does not execute useEffect and has incomplete hook support. Fix: Migrate tests to React Testing Library which tests behavior rather than implementation. [src5]useRef<T>() without an argument is a TypeScript error in React 19's updated type definitions. Fix: Use useRef<T>(undefined) or useRef<T>(null) explicitly. [src8]# 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 | Status | Breaking Changes | Migration Notes |
|---|---|---|---|
| React 19.2 (2025-10) | Current | Bug fixes, perf improvements | Latest stable; React Compiler generally available |
| React 19 (2024-12) | Current | forwardRef removed (ref is a regular prop), use() API, useActionState/useOptimistic/useFormStatus hooks | Class components still supported; ref can be passed directly as prop; auto-memoization via Compiler |
| React 18 (2022) | LTS | ReactDOM.render deprecated, Strict Mode double-invokes effects | Replace ReactDOM.render() with createRoot().render(); effects must be idempotent |
| React 17 (2020) | Maintenance | New JSX transform, event delegation on root | No import React needed in JSX files with new transform |
| React 16.8 (2019) | EOL | Hooks introduced | Minimum version for hooks; class components work in all versions |
| Use When | Don't Use When | Use Instead |
|---|---|---|
| Writing new components in an existing codebase | Component uses getSnapshotBeforeUpdate | Keep as class component |
| Component has tangled lifecycle logic (mount + update + unmount) | Component is a working Error Boundary | Keep class or use react-error-boundary |
| You need to extract reusable stateful logic (custom hooks) | Class component is stable, well-tested, and won't change | Leave it alone |
| Team is adopting TypeScript (hooks have simpler type signatures) | You're on React < 16.8 and can't upgrade | Upgrade React first |
| Component logic is duplicated via HOCs/render props | Migration deadline is tight and tests are insufficient | Add tests first, then migrate |
| Adopting React 19 features (Actions, use(), Compiler) | Class component uses patterns with no hook equivalent | Keep as class until equivalent exists |
eslint-plugin-react-hooks.useEffect cleanup runs before every re-execution of the effect (not just on unmount), which differs from componentWillUnmount. This is by design for subscription-based effects.useLayoutEffect should be used sparingly — only when you need synchronous DOM reads/writes before paint. For most cases, useEffect is correct and more performant.use() API can read Promises and Context in render, offering patterns that were impossible with class components. This is a strong reason to migrate components that need suspense-based data fetching.useMemo, useCallback, and React.memo. For new projects using the Compiler, skip manual memoization.