Mở gần như bất kỳ codebase React nào, bạn cũng sẽ nhận thấy cùng một phản xạ. Một lập trình viên cần theo dõi một giá trị, và họ tìm đến useState. Cần một bộ đếm? useState. Một giá trị input tạm thời? useState. Một biến boolean để đóng/mở modal? useState. Chẳng bao lâu sau, một component duy nhất chứa hàng tá hook riêng biệt, mỗi cái quản lý một mẩu dữ liệu nhỏ mà có thể cần hoặc không cần duy trì qua các lần render. Kết quả là mã nguồn trở nên rườm rà, phát sinh thêm các lần re-render không cần thiết, và state bị phân tán lung tung như tiền lẻ khắp component.
Thói quen này là có thể hiểu được. useState là hook đầu tiên mà hầu hết chúng ta được học, và nó hoạt động tốt. Nhưng "hoạt động được" không đồng nghĩa với việc "phù hợp". Việc coi mọi mẩu dữ liệu đều là reactive state sẽ tạo ra những vấn đề chỉ lộ diện khi component phát triển lớn hơn.
Chỉ Vì Nó Thay Đổi Không Có Nghĩa Là Nó Cần State
Không phải mọi biến số thay đổi theo thời gian đều nên nằm trong useState. Một số giá trị chỉ đơn thuần là kết quả của một thứ khác mà bạn đã có sẵn. Nếu bạn lưu tên đầy đủ của người dùng trong state chỉ vì bạn nối chuỗi firstName và lastName, bạn đang có hai nguồn dữ liệu gốc (sources of truth). Khi firstName cập nhật do component cha re-render, state fullName của bạn sẽ bị cũ cho đến khi bạn chạy một effect khác để đồng bộ nó. Bạn không cần một sync effect. Bạn cần một giá trị phái sinh (derived value).
const fullName = `${firstName} ${lastName}`;
Hãy tính toán nó ngay trong quá trình render. Nếu việc tính toán phái sinh đó tốn kém, hãy memoize nó. Nhưng đừng cấp cho nó một hook useState riêng trừ khi người dùng có thể chỉnh sửa tên đầy đủ đó một cách độc lập với các thành phần cấu thành.
Quy tắc tương tự cũng áp dụng cho các danh sách đã được lọc. Nếu bạn giữ cả allItems và filteredItems trong state, bạn đã làm tăng gấp đôi khối lượng cần bảo trì. Hãy lọc trong quá trình render. Giữ mảng nguồn và văn bản bộ lọc trong state, sau đó phái sinh danh sách hiển thị từ đó. Điều này đảm bảo rằng danh sách đã lọc không bao giờ bị mất đồng bộ với nguồn.
Một Số Giá Trị Không Bao Giờ Nên Kích Hoạt Re-renders
useState tồn tại cụ thể để báo cho React biết rằng có thứ gì đó đã thay đổi và DOM có thể cần được cập nhật. Nếu một giá trị thay đổi nhưng không có phần nào của UI quan tâm đến sự thay đổi đó, useRef sẽ là công cụ tốt hơn.
Timer và interval là ví dụ điển hình. Việc lưu trữ ID của setInterval trong state sẽ gây ra một lần re-render mỗi khi bạn bắt đầu hoặc dừng một timer, mặc dù người dùng không thể nhìn thấy ID của interval đó. Một ref sẽ giữ giá trị đó mà không thông báo cho React. Logic tương tự cũng áp dụng cho việc theo dõi các props trước đó, đo lường các DOM node trước khi paint, hoặc lưu trữ callback mới nhất cho một custom hook. Hãy tự hỏi bản thân: giá trị này có cần hiển thị trên màn hình không? Nếu câu trả lời là không, nó có lẽ không cần useState.
Bản thân các DOM node cũng thuộc về refs. Mặc dù bạn có thể lưu trữ một DOM element trong state, nhưng làm như vậy sẽ kích hoạt một lần re-render sau khi ref callback chạy. Trong hầu hết các trường hợp, bạn chỉ cần node đó cho một phương thức mệnh lệnh (imperative method) hoặc để đo lường, chứ không phải để render nó theo cách khác.
Cạm Bẫy Boolean
Các vấn đề liên quan đến UI có xu hướng trở nên mất kiểm soát khi mỗi flag (biến cờ) đều có hook riêng. Bạn sẽ thấy các component có isLoading, isError, và isSuccess được định nghĩa là ba biến boolean riêng biệt. Vấn đề là ba trạng thái này không độc lập với nhau. Nếu cả isLoading và isSuccess đều là true, UI của bạn đang ở trong một trạng thái không thể xảy ra, vậy mà TypeScript và React vẫn cho phép bạn render nó.
Việc nhóm các state liên quan lại với nhau sẽ ngăn chặn các tổ hợp không hợp lệ này. Thay vì dùng ba biến boolean, hãy theo dõi một chuỗi trạng thái duy nhất: 'idle', 'loading', 'success', hoặc 'error'. Chỉ có một trạng thái có thể hoạt động tại một thời điểm, điều này giúp loại bỏ các trạng thái bất khả thi ngay từ cấp độ kiểu dữ liệu (type level). Nếu dữ liệu phức tạp hơn, một object với discriminated union sẽ giúp mọi thứ gọn gàng hơn nữa. Khi bạn thấy mình đang cập nhật nhiều lệnh gọi useState bên trong cùng một event handler, đó là dấu hiệu cho thấy các giá trị đó nên thuộc về nhau.
Hãy Dùng useReducer, Thay Vì Thêm Một useState Khác
Sẽ đến lúc việc cập nhật state trở thành một trò chơi "đập chuột" (whack-a-mole). Bạn gọi setA, sau đó setB, rồi lại gọi setC một cách có điều kiện, tất cả đều nằm trong một hàm. Lập trình viên tiếp theo đọc mã nguồn đó sẽ phải lần mò qua trình tự để hiểu xem component thực sự làm gì.
useReducer tỏa sáng ở đây. Nó không thay thế useState vì nó nâng cao hơn; nó thay thế useState vì logic yêu cầu như vậy. Một reducer tập trung hóa cách state thay đổi. Thay vì rải rác các lệnh mệnh lệnh khắp các event handler, bạn dispatch một ý định: dispatch({ type: 'submitted' }). Reducer sẽ quyết định trạng thái tiếp theo trông như thế nào. Điều này giúp việc kiểm thử trở nên cực kỳ đơn giản, vì logic state của bạn là một hàm thuần khiết (pure function). Nó cũng giúp việc debug dễ dàng hơn, vì mỗi thay đổi đều để lại một action có thể truy vết.
Bạn không cần Redux để biện minh cho việc sử dụng một reducer. Nếu bạn có từ ba biến state trở lên cập nhật cùng lúc, hoặc nếu state tiếp theo phụ thuộc nhiều vào state trước đó, một reducer sẽ giúp đơn giản hóa component một cách đáng kể.
Nơi state thực sự tồn tại
Đôi khi vấn đề không nằm ở cách bạn lưu trữ state, mà là ở nơi lưu trữ. Một sai lầm phổ biến là hoisting state lên component cha chỉ vì nó có thể cần được sử dụng ở nơi khác. Nếu chỉ có một leaf component sử dụng một phần của state, hãy giữ nó ở đó. Đây chính là colocation, và nó giúp giảm thiểu phạm vi ảnh hưởng của các thay đổi. Đừng làm cho component cha phải re-render chỉ vì một component con vừa mở
