Chuyển từ class sang hooks, nhiều người map thẳng componentDidMount thành useEffect(() => {}, []) rồi dính bug: effect chạy sai lúc, cleanup không đúng, hoặc chạy vô hạn. Vấn đề là lifecycle (nghĩ theo “thời điểm”: mount, update, unmount) và hooks (nghĩ theo “đồng bộ hoá với dữ liệu”) là hai mô hình khác nhau, map 1-1 không phải lúc nào cũng đúng.
Bài này map đầy đủ class lifecycle sang hook tương đương, kèm một bảng tra nhanh, rồi chỉ ra đúng những chỗ không map 1-1 — nơi bạn dễ sai nhất.
1. Lifecycle class component: mount, update, unmount
Một class component đi qua ba giai đoạn. React gọi các lifecycle method theo đúng thứ tự dưới đây, và biết thứ tự này là điều kiện để map sang hook cho chuẩn.
View Mermaid diagram code
flowchart TD
Start([Component được tạo]) --> C[constructor]
C --> GDSFP1["getDerivedStateFromProps()"]
GDSFP1 --> R1[render]
R1 --> CDM["componentDidMount()"]
CDM --> Idle([Đang hiển thị])
Idle -->|props/state đổi| GDSFP2["getDerivedStateFromProps()"]
GDSFP2 --> SCU{"shouldComponentUpdate()?"}
SCU -->|false| Idle
SCU -->|true| R2[render]
R2 --> GSBU["getSnapshotBeforeUpdate()"]
GSBU --> CDU["componentDidUpdate()"]
CDU --> Idle
Idle -->|bị gỡ khỏi cây| CWU["componentWillUnmount()"]
CWU --> Removed([Đã unmount])
style CDM fill:#1a2c08,stroke:#c7ff50,stroke-width:1.5px,color:#c7ff50
style CDU fill:#1a2c08,stroke:#c7ff50,stroke-width:1.5px,color:#c7ff50
style CWU fill:#1a2c08,stroke:#c7ff50,stroke-width:1.5px,color:#c7ff50
style SCU fill:#2a2008,stroke:#ffc043,stroke-width:1.5px,color:#ffd98a
style GSBU fill:#0e2233,stroke:#4aa8ff,stroke-width:1.5px,color:#9ecbff
style GDSFP1 fill:#0e2233,stroke:#4aa8ff,stroke-width:1.5px,color:#9ecbff
style GDSFP2 fill:#0e2233,stroke:#4aa8ff,stroke-width:1.5px,color:#9ecbffBa method tô lime là những cái bạn dùng nhiều nhất và map thẳng được sang useEffect. Hai method màu xanh (getDerivedStateFromProps, getSnapshotBeforeUpdate) hiếm gặp hơn và cần cách xử lý khác — phần 4 sẽ nói.
1.1. Mount
Khi component xuất hiện lần đầu: constructor (khởi tạo state) → getDerivedStateFromProps → render → componentDidMount. Method componentDidMount chạy sau khi DOM đã có, nên đây là chỗ mở connection, fetch data, hay subscribe.
1.2. Update
Khi props hoặc state đổi: getDerivedStateFromProps → shouldComponentUpdate (trả về false để chặn re-render) → render → getSnapshotBeforeUpdate (đọc DOM ngay trước khi React ghi thay đổi) → componentDidUpdate.
1.3. Unmount
Trước khi component bị gỡ khỏi cây: componentWillUnmount. Đây là chỗ dọn dẹp — đóng connection, huỷ subscribe, clear timer.
Một ví dụ gộp cả ba giai đoạn: component ChatRoom mở connection theo roomId.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
import { Component } from "react";
import { createConnection } from "./chat";
class ChatRoom extends Component {
state = { serverUrl: "https://localhost:1234" };
componentDidMount() {
// Sau lần render đầu: mở connection
this.connection = createConnection(this.state.serverUrl, this.props.roomId);
this.connection.connect();
}
componentDidUpdate(prevProps) {
// roomId đổi → đóng connection cũ rồi mở connection mới
if (this.props.roomId !== prevProps.roomId) {
this.connection.disconnect();
this.connection = createConnection(this.state.serverUrl, this.props.roomId);
this.connection.connect();
}
}
componentWillUnmount() {
// Trước khi bị gỡ: dọn dẹp
this.connection.disconnect();
}
render() {
return <h1>Welcome to {this.props.roomId} room!</h1>;
}
}Để ý logic mở connection bị lặp ở hai chỗ (componentDidMount và componentDidUpdate), và logic đóng cũng nằm rải rác. Đây chính là điểm hook làm gọn hơn.
2. Functional component + hooks tương đương
Functional component không có “lifecycle method”. Thay vào đó bạn khai báo các hook, và React tự quyết định khi nào chạy chúng dựa trên dependencies.
2.1. useEffect gộp mount, update và unmount
useEffect nhận một setup function; function này có thể return một cleanup function. React chạy setup sau khi commit DOM; chạy cleanup trước lần setup kế tiếp và một lần cuối khi unmount. Dependency array quyết định “khi nào chạy lại”:
1
2
3
4
5
6
7
8
9
10
11
useEffect(() => {
// Chạy sau MỖI lần render
});
useEffect(() => {
// Chỉ chạy một lần khi mount ([] = không có dep nào để đổi)
}, []);
useEffect(() => {
// Chạy khi mount, và mỗi khi roomId hoặc userId đổi
}, [roomId, userId]);Viết lại ChatRoom ở trên bằng một useEffect duy nhất: cả componentDidMount, componentDidUpdate và componentWillUnmount gộp làm một, không còn logic lặp.
1
2
3
4
5
6
7
8
9
10
11
12
13
import { useEffect } from "react";
import { createConnection } from "./chat";
function ChatRoom({ roomId }) {
useEffect(() => {
const connection = createConnection("https://localhost:1234", roomId);
connection.connect();
// Cleanup: chạy trước lần chạy effect kế tiếp và khi unmount
return () => connection.disconnect();
}, [roomId]); // roomId đổi → cleanup connection cũ rồi tạo connection mới
return <h1>Welcome to {roomId} room!</h1>;
}Setup + cleanup nằm cạnh nhau nên khó quên dọn dẹp — một lỗi rất hay gặp với componentWillUnmount viết tách rời.
2.2. useLayoutEffect đọc layout trước khi browser paint
useEffect chạy sau khi browser đã paint, nên nếu bạn đo kích thước DOM rồi setState trong đó, người dùng có thể thấy giao diện nhấp nháy một nhịp. useLayoutEffect có cùng chữ ký nhưng chạy sau khi commit DOM và trước khi browser paint — nó chặn paint cho tới khi chạy xong, nên bạn đo và chỉnh layout kịp trước khi màn hình hiện ra.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
import { useLayoutEffect, useRef, useState } from "react";
function Tooltip() {
const ref = useRef(null);
const [height, setHeight] = useState(0);
useLayoutEffect(() => {
// Đọc kích thước DOM sau commit, TRƯỚC khi browser paint → không nhấp nháy
const rect = ref.current.getBoundingClientRect();
setHeight(rect.height);
}, []);
return <div ref={ref}>Tooltip cao {height}px</div>;
}Đây là hook gần nhất với cặp getSnapshotBeforeUpdate + componentDidUpdate của class: cùng giải quyết bài toán “đọc số đo DOM rồi chỉnh lại trước khi người dùng thấy” (ví dụ giữ nguyên scroll position khi thêm item). Lưu ý timing không trùng khít: getSnapshotBeforeUpdate đọc DOM trước khi React ghi thay đổi, còn useLayoutEffect chạy sau khi DOM đã đổi nhưng trước paint. Vì cả hai đều đồng bộ trước paint nên xử lý được cùng một nhóm vấn đề. Vì nó chặn paint, chỉ dùng khi thật sự cần đo layout — còn lại ưu tiên useEffect.
2.3. React.memo thay cho shouldComponentUpdate
shouldComponentUpdate và PureComponent cho phép class bỏ qua re-render khi props/state không đổi. Bên functional, React.memo bọc component để nó chỉ re-render khi props đổi (shallow compare):
1
2
3
4
5
6
import { memo } from "react";
// Chỉ re-render khi prop `name` đổi — giống PureComponent
const Greeting = memo(function Greeting({ name }) {
return <h3>Hello, {name}!</h3>;
});Cần lưu ý: memo so sánh props theo shallow. Nếu bạn truyền xuống một object hay function tạo mới mỗi lần render, shallow compare luôn thấy “khác” và memo mất tác dụng. Đó là lúc cần đến useMemo (nhớ một giá trị) và useCallback (nhớ một function) — giữ nguyên reference giữa các lần render để memo hoạt động. Chi tiết về re-render thừa và cách dùng ba công cụ này cho đúng là một chủ đề riêng; ở đây chỉ cần nhớ chúng cùng đóng vai trò của shouldComponentUpdate.
3. Bảng mapping lifecycle sang hook
Tra nhanh: một class lifecycle method tương ứng với phần nào bên functional.
| Class lifecycle | Functional + hooks |
|---|---|
constructor (khởi tạo state) | useState / useReducer |
render | thân function component (return JSX) |
componentDidMount | useEffect(() => {...}, []) |
componentDidUpdate | useEffect(() => {...}, [deps]) (cũng chạy khi mount) |
componentWillUnmount | cleanup return () => {...} trong useEffect |
getSnapshotBeforeUpdate + componentDidUpdate | useLayoutEffect (≈ cùng use case, khác timing) |
shouldComponentUpdate / PureComponent | React.memo (+ useMemo / useCallback) |
getDerivedStateFromProps | tính khi render / reset bằng key |
getDerivedStateFromError / componentDidCatch | chưa có hook — dùng class hoặc react-error-boundary |
Các dòng đầu map khá sát — riêng useEffect([deps]) còn chạy cả lúc mount (khác componentDidUpdate), và useLayoutEffect chỉ tương đương về use case chứ khác timing như mục 2 đã nói. Ba dòng cuối mới là chỗ tư duy “theo thời điểm” của class không còn đúng — phần tiếp theo đi vào từng cái.
4. Những chỗ KHÔNG map 1-1
4.1. getDerivedStateFromProps
getDerivedStateFromProps thường được dùng để tính state phái sinh từ props, hoặc reset state khi một prop đổi. Bên functional, phần lớn các trường hợp bạn không cần cả hai.
Nếu chỉ là “tính một giá trị từ props/state”, hãy tính thẳng khi render — không cần lưu vào state:
1
2
3
4
5
// Không cần state phái sinh: tính thẳng khi render
function TodoList({ todos, filter }) {
const visibleTodos = getFilteredTodos(todos, filter);
return <ul>{visibleTodos.map((t) => <li key={t.id}>{t.text}</li>)}</ul>;
}Nếu tính toán nặng, bọc bằng useMemo để chỉ tính lại khi todos hoặc filter đổi. Còn nếu ý định thật sự là “reset toàn bộ state khi một prop đổi” (ví dụ đổi user thì xoá form cũ), cách sạch nhất là truyền prop đó làm key — React coi đây là component khác và tự reset mọi state bên trong:
1
2
3
4
// Đổi userId → Profile được tạo lại từ đầu, mọi state bên trong reset
function ProfilePage({ userId }) {
return <Profile userId={userId} key={userId} />;
}4.2. Error boundary vẫn phải là class
Đây là chỗ hooks chưa thay thế được. getDerivedStateFromError (đổi state để hiển thị fallback UI) và componentDidCatch (log lỗi) chỉ chạy được trong class component — chưa có hook tương đương.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
import { Component } from "react";
class ErrorBoundary extends Component {
state = { hasError: false };
static getDerivedStateFromError() {
// Đổi state để lần render kế hiển thị fallback UI
return { hasError: true };
}
componentDidCatch(error, info) {
// Log lỗi lên service (không có hook tương đương)
logErrorToService(error, info);
}
render() {
if (this.state.hasError) return <FallbackUI />;
return this.props.children;
}
}Nếu không muốn tự viết class, dùng package react-error-boundary — nó bọc sẵn một <ErrorBoundary> với API thân thiện cho functional component. Nhưng phía dưới, error boundary vẫn dựa trên hai method class kể trên.
5. Đổi tư duy: useEffect là “đồng bộ với deps”, không phải “onMount”
Đây là điểm mấu chốt khiến nhiều người map sai. useEffect không phải là “làm gì đó lúc mount” hay “làm gì đó lúc update”. Đúng hơn: nó đồng bộ một side effect với các dependency. Mỗi khi một dep đổi, React chạy cleanup của lần trước rồi chạy lại setup — để side effect luôn khớp với dữ liệu hiện tại.
Nhìn lại ChatRoom: bạn không “mở connection lúc mount rồi mở lại lúc roomId update”. Bạn khai báo “connection này phải khớp với roomId“. roomId đổi thì React tự đóng connection cũ và mở cái mới — không phải vì “đến thời điểm update”, mà vì “dep đổi nên cần đồng bộ lại”. Bỏ được khung suy nghĩ theo thời điểm là lúc dependency array trở nên tự nhiên.
Một hệ quả hay gây bối rối: ở development với StrictMode, React cố tình chạy setup rồi cleanup rồi setup lại một lần ngay khi mount, để lộ những effect thiếu cleanup. Vì sao effect chạy 2 lần, stale closure, và cách chọn dependency array cho đúng là cơ chế sâu hơn của hooks — thuộc về một bài riêng; bài này dừng ở việc map lifecycle sang hook. Nếu bạn cần chuẩn hoá cả kiểu dữ liệu cho props và component, xem thêm typing props và components trong React TypeScript.
Tóm lại: sáu lifecycle method thông dụng map thẳng sang useEffect / useLayoutEffect / React.memo, nhưng getDerivedStateFromProps nên đổi thành tính-khi-render hoặc reset bằng key, còn error boundary thì vẫn phải là class. Khi map, đừng dịch từng “thời điểm” — hãy hỏi “side effect này cần đồng bộ với dep nào”, và bạn sẽ viết useEffect đúng ngay từ đầu.