Giới thiệu
Trong React, hook useState cho phép quản lý trạng thái trong các component function. Bài viết này sẽ giải thích cách useState hoạt động dưới lớp vỏ, tại sao nó lại cần sử dụng chỉ thị "use client" trong Next.js, và cung cấp các mẹo thực tiễn để sử dụng hiệu quả hơn.
1) Trạng thái thực sự tồn tại ở đâu
- React xây dựng một cây Fibers (mỗi component có một Fiber riêng).
- Mỗi component hàm có một danh sách hook được lưu trữ trên fiber của nó (một cấu trúc giống như danh sách liên kết/mảng).
- Mỗi lần gọi
useState(...)tương ứng với một ô hook trong danh sách đó. Thứ tự phải ổn định giữa các lần render.
2) Mount và update
- Mount (render lần đầu):
- React tạo một ô hook với:
memoizedState: giá trị hiện tại (tham số khởi tạo hoặc kết quả của hàm khởi tạo lười).queue: một hàng đợi các cập nhật đang chờ xử lý (danh sách liên kết).- Trả về
[state, dispatch].
- React tạo một ô hook với:
- Update (render lại):
- React duyệt qua các hooks theo cùng một thứ tự và với mỗi
useState:- Xả hàng đợi
queuecác cập nhật, áp dụng chúng theo thứ tự để tạo ramemoizedStatemới. - Trả về một hàm
dispatchổn định.
- Xả hàng đợi
- React duyệt qua các hooks theo cùng một thứ tự và với mỗi
Thứ tự rất quan trọng: React xác định mỗi hook theo vị trí gọi trong component. Thay đổi số lượng/thứ tự của hooks giữa các lần render sẽ phá vỡ ánh xạ này.
3) Thực tế của setState (dispatch)
- Gọi
setState(payload)không thay đổi ngay lập tức. - Nó tạo ra một đối tượng cập nhật và đẩy nó vào hàng đợi của hook.
- React lên lịch làm việc trên fiber bị ảnh hưởng (với ưu tiên thích hợp).
- Trong lần render tiếp theo, React giảm tất cả các cập nhật đã xếp hàng để tính toán trạng thái tiếp theo.
Các loại payload
- Giá trị:
setCount(3)→ thay thế bằng3. - Hàm updater:
setCount(c => c + 1)→ React gọi nó với trạng thái trước đó. - Khởi tạo lười (chỉ trên mount):
useState(() => heavyInit())chạyheavyInit()một lần.
4) Cảm giác “Asynchronous” và batching
- Trong React 18+, các cập nhật trong cùng một tick được batch (sự kiện trình duyệt, promises, timeouts, v.v.).
- Bạn có thể không thấy trạng thái mới cho đến khi render xử lý hoàn tất hàng đợi.
flushSynccó thể buộc đồng bộ (sử dụng cẩn thận).
5) Đối tượng đồng thời & ưu tiên (React 18)
- React có thể gián đoạn một lần render (ví dụ: các cập nhật ưu tiên thấp) và tiếp tục với dữ liệu mới hơn.
startTransitionđánh dấu các cập nhật là chuyển tiếp (ưu tiên thấp hơn) để việc gõ vẫn mượt mà.- Hàng đợi trạng thái làm điều này khả thi vì React có thể chạy lại pipeline reducer một cách xác định.
6) Closures & những điều cần lưu ý
- Các handler nắm bắt các biến từ render mà chúng được tạo ra. Nếu bạn sử dụng
setX(x + 1)trong mã bất đồng bộ, bạn có thể sử dụng mộtxđã cũ. Ưu tiên hình thức hàm:setX(prev => prev + 1). - Không bao giờ gọi hooks trong vòng lặp/điều kiện. Giữ cho thứ tự gọi ổn định.
Tại sao useState cần "use client" trong Next.js App Router
Server Components (RSC) được render trên máy chủ. Chúng:
- Phải là đầu ra có thể tuần tự (payload giống JSX/JSON).
- Không thể giữ trạng thái client runtime, đính kèm các handler sự kiện, hoặc truy cập DOM.
- Không gửi mã của chúng đến trình duyệt (không có JS client).
useState, useEffect, các handler sự kiện (onClick), v.v. yêu cầu một runtime client (trình duyệt hoặc React DOM trên client). Do đó:
- Bất kỳ file nào sử dụng
useStatephải bắt đầu với:
javascript
"use client";
Điều này đánh dấu file là một Client Component để:
- Nó được đóng gói cho trình duyệt.
- Nó có thể chạy hooks, giữ trạng thái giữa các lần render, xử lý các sự kiện.
- Nó không thể nhập các mô-đun chỉ dành cho server (khách hàng DB,
fs, hành động server trực tiếp, v.v.).
Mô hình tư duy
- Server Component = lấy/biên soạn dữ liệu, không có UI hooks có trạng thái, không có JS client mặc định.
- Client Component = UI tương tác (trạng thái, hiệu ứng, refs, handler sự kiện).
Mẫu điển hình (ranh giới server → client)
javascript
// app/posts/page.tsx (Server Component mặc định)
import PostList từ "./PostList";
export default async function Page() {
const posts = await fetchPosts(); // dữ liệu phía server
return <PostList initialPosts={posts} />;
}
javascript
// app/posts/PostList.tsx
"use client";
import { useState } từ "react";
export default function PostList({ initialPosts }) {
const [filter, setFilter] = useState("");
const visible = initialPosts.filter(p => p.title.includes(filter));
return (
<>
<input value={filter} onChange={e => setFilter(e.target.value)} />
<ul>{visible.map(p => <li key={p.id}>{p.title}</li>)}</ul>
</>
);
}
- Server tải dữ liệu một cách hiệu quả.
- Client component xử lý trạng thái & tính tương tác.
- Chỉ
PostList(và các phụ thuộc chỉ client của nó) được gửi đến trình duyệt.
Mẹo thực tiễn
- Ưu tiên cập nhật hàm khi trạng thái tiếp theo phụ thuộc vào trước đó:
javascript
setCount(c => c + 1);
- Sử dụng khởi tạo lười cho trạng thái khởi tạo tốn kém:
javascript
const [value] = useState(() => computeOnce());
- Tránh việc tính toán trạng thái mà bạn có thể tính từ props trong quá trình render; tính toán ngay hoặc lưu trữ.
- Nếu một component chỉ cần trạng thái cho một phần nhỏ, hãy xem xét một child client nhỏ thay vì đánh dấu cả trang với "use client".
- Nhớ rằng: mỗi cuộc gọi
useStatelà theo từng instance. Mỗi instance component được gắn có các ô hook và hàng đợi riêng của nó.
Kết luận
Hy vọng bài viết này đã giúp bạn hiểu rõ hơn về cách hoạt động của useState và lý do tại sao nó yêu cầu chỉ thị "use client" trong Next.js. Để tìm hiểu thêm về React và các hook, hãy tham khảo tài liệu chính thức và thực hành trên các dự án của bạn. Nếu bạn có câu hỏi hoặc muốn chia sẻ kinh nghiệm của mình, hãy để lại ý kiến dưới bài viết này nhé!