How to Migrate React Class Components to Functional Components with Hooks
How do I migrate React class components to functional components with hooks?
TL;DR
- Bottom line: Replace class components one at a time — convert
this.state/this.setStatetouseState, lifecycle methods (componentDidMount,componentDidUpdate,componentWillUnmount) touseEffectwith dependency arrays, andthis.contexttouseContext. Error boundaries remain class-only unless you usereact-error-boundary. React 19's Compiler auto-memoizes, reducing the need for manualuseMemo/useCallback. - Key tool/command:
npx react-codemod rename-unsafe-lifecycles+eslint-plugin-react-hooks - Watch out for: Treating
useEffect(fn, [])as identical tocomponentDidMount— it fires after paint (not synchronously), captures stale closures, and requires dependencies thatcomponentDidMountnever needed. - Works with: React 16.8+ (hooks introduced), React 17, React 18, React 19, React 19.2 (current — adds
useEffectEvent,<Activity />, batched SSR Suspense, ESLint plugin v6 with React Compiler rules). All major bundlers (Webpack, Vite, esbuild). [src9]
Constraints
- React 16.8+ is the minimum version for hooks — class components work in all versions but hooks require 16.8+
- Hooks must be called at the top level of function components — never inside loops, conditions, or nested functions (Rules of Hooks, enforced by
eslint-plugin-react-hooks) - Error Boundaries still require class components (
componentDidCatch/getDerivedStateFromError) — usereact-error-boundaryas the hook-based alternative getSnapshotBeforeUpdatehas no hook equivalent — keep those components as classesuseEffectfires asynchronously after paint — useuseLayoutEffectfor synchronous DOM measurements before repaint- React 19 React Compiler auto-memoizes — manual
useMemo/useCallbackmay be unnecessary in new projects using the compiler - React 19.2
useEffectEvent(stable) decouples non-reactive event logic from Effects — use it to read latest props/state without re-running the effect (replaces ref-based workarounds)
Quick Reference
| 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 }) { ... } |
Instance method reading this.props inside class subscription | useEffectEvent (React 19.2) | const onTick = useEffectEvent(() => log(theme)) — reads latest theme without re-running effect |
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: Adding non-reactive values to useEffect deps just to read their latest value
// ❌ BAD — theme is not what triggers reconnection, but adding it to deps re-runs the effect on every theme change
function ChatRoom({ roomId, theme }) {
useEffect(() => {
const conn = createConnection(roomId);
conn.on('connected', () => showNotification('Connected!', theme));
conn.connect();
return () => conn.disconnect();
}, [roomId, theme]); // theme change causes unwanted reconnect
}
Correct: Use useEffectEvent (React 19.2) for non-reactive event logic
// ✅ GOOD — useEffectEvent reads latest theme without re-running the effect
import { useEffect, useEffectEvent } from 'react';
function ChatRoom({ roomId, theme }) {
const onConnected = useEffectEvent(() => {
showNotification('Connected!', theme); // always sees latest theme
});
useEffect(() => {
const conn = createConnection(roomId);
conn.on('connected', () => onConnected());
conn.connect();
return () => conn.disconnect();
}, [roomId]); // only roomId triggers reconnect [src9]
}
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
- Stale closures in useEffect:
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] - useEffect fires after paint, not synchronously: Unlike
componentDidMountwhich runs before the browser paints,useEffectruns after paint. This causes visible flicker for synchronous DOM measurements. Fix: UseuseLayoutEffectwhen you need to measure/mutate the DOM before the browser repaints. [src3] - Infinite re-render loops: Creating objects, arrays, or functions inside the component body and passing them as effect dependencies causes infinite loops. Fix: Move static values outside the component, or wrap with
useMemo/useCallback. With React 19 Compiler, auto-memoization handles many of these cases. [src4, src8] - Multiple useState calls update independently: Unlike
this.setStatewhich merges state,useStatesetter replaces the entire value. Fix: Use the spread operator:setState(prev => ({ ...prev, name: 'new' })), or useuseReducerfor complex related state. [src1] - setState callback second argument doesn't exist in hooks: Class
this.setState({}, callback)runs callback after state update. Hooks have no equivalent. Fix: UseuseEffectwatching the state variable to run code after it changes. [src6] - Async functions directly in useEffect:
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] - Converting shouldComponentUpdate literally to React.memo:
shouldComponentUpdatereceives both props and state;React.memoonly compares props. Fix: Restructure the component to lift state up or useuseMemofor expensive derived computations. [src1] - Losing instance variables: Class instance properties (
this.timer,this.isMounted) disappear in function components. UsinguseStatefor these causes unnecessary re-renders. Fix: UseuseReffor any mutable value that should not trigger a re-render. [src4] - Enzyme shallow rendering breaks with hooks: Enzyme's
shallow()does not executeuseEffectand has incomplete hook support. Fix: Migrate tests to React Testing Library which tests behavior rather than implementation. [src5] - useRef requires initializer in React 19 TypeScript:
useRef<T>()without an argument is a TypeScript error in React 19's updated type definitions. Fix: UseuseRef<T>(undefined)oruseRef<T>(null)explicitly. [src8] - Calling useEffectEvent outside an Effect or passing it to children: React 19.2's
useEffectEventcan only be called from inside Effects or other Effect Events of the same component — calling it during render or passing it as a prop is a runtime error. Fix: Keep the event handler local to the component that declares it; wrap the call site inuseEffect. [src9]
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
| Version | Status | Breaking Changes | Migration Notes |
|---|---|---|---|
| React 19.2 (2025-10) | Current | useEffectEvent stable, <Activity /> component, cacheSignal() (RSC), partial pre-rendering, batched SSR Suspense, eslint-plugin-react-hooks v6 with React Compiler rules | Use useEffectEvent to read latest props/state inside Effects without re-running them — replaces ref-based workarounds during class migrations |
| 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 |
When to Use / When Not to Use
| 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 |
Important Caveats
- Hooks can only be called at the top level of function components or custom hooks — never inside loops, conditions, or nested functions. This is enforced by
eslint-plugin-react-hooks. useEffectcleanup runs before every re-execution of the effect (not just on unmount), which differs fromcomponentWillUnmount. This is by design for subscription-based effects.- React 18 Strict Mode double-invokes effects in development to help surface cleanup bugs. This causes apparent "double mounting" that does not happen in production.
- Class components are NOT deprecated as of React 19. The React team has no plans to remove them. Migration is recommended for new code but not mandated for existing stable code.
useLayoutEffectshould be used sparingly — only when you need synchronous DOM reads/writes before paint. For most cases,useEffectis correct and more performant.- React 19's
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. - The React Compiler (React 19) auto-memoizes components and hooks, reducing the need for manual
useMemo,useCallback, andReact.memo. For new projects using the Compiler, skip manual memoization. - React 19.2 (Oct 2025) added
useEffectEventas a stable API for separating non-reactive event logic from Effects — this is the canonical replacement for class instance methods that readthis.props/this.statefrom subscription callbacks. The<Activity />component is also new in 19.2 and lets you pre-render hidden pages without their effects running, useful when migrating route-level class components. [src9]