Mọi lập trình viên React cuối cùng đều sẽ vấp phải cùng một rào cản. Bạn fetch một đối tượng user bên trong component App cấp cao nhất. Sau đó, bạn truyền nó xuống. Rồi lại truyền xuống tiếp. Qua một route wrapper, qua một layout shell, qua một sidebar container, chỉ để một component avatar nhỏ xíu nằm sâu ba lớp có thể hiển thị ảnh đại diện. Các component ở giữa không hề quan tâm đến đối tượng user đó. Chúng chỉ đơn thuần là đang chuyển tiếp gói hàng. Đó chính là prop drilling, và nó biến một cây component sạch sẽ thành một trò chơi truyền tin đầy ức chế.

Nỗi đau thực sự bắt đầu khi cấu trúc của dữ liệu đó thay đổi. Có thể backend bắt đầu lồng dữ liệu theo kiểu user.profile.avatar thay vì user.avatar. Đột nhiên, bạn phải chỉnh sửa các TypeScript interface hoặc PropTypes trong năm tệp tin mà bản thân chúng chẳng bao giờ sử dụng dữ liệu đó. Đó chính là lúc React Context API xuất hiện để giải quyết vấn đề.

Cách Context tái cấu trúc luồng dữ liệu

Hãy coi Context như một bộ định tuyến WiFi đặt ở trung tâm ngôi nhà của bạn. Nếu không có nó, bạn sẽ cần những sợi cáp Ethernet chạy ngoằn ngoèo qua mọi căn phòng để đưa tín hiệu đến laptop. Với nó, bộ định tuyến sẽ phát sóng qua không trung, và bất kỳ thiết bị nào có mật khẩu đúng đều có thể kết nối trực tiếp. Những bức tường không còn là vấn đề nữa.

Theo thuật ngữ của React, gốc (root) của ứng dụng có thể phát sóng dữ liệu thông qua cây component mà không cần yêu cầu mọi lớp trung gian phải đóng vai trò là người đưa thư. Bất kỳ component con nào cũng có thể đăng ký nhận luồng phát sóng đó và nhận chính xác những gì nó cần.

Ba thành phần cốt lõi

Context API gói gọn trong ba thành phần chính.

React.createContext() thiết lập kênh phát sóng. Nó trả về một đối tượng chứa một Provider và (trong các mã nguồn cũ) một Consumer. Bạn chỉ cần gọi hàm này một lần cho một tính năng cụ thể.

The Provider là một component bao bọc một phần cây component của bạn. Nó chấp nhận một prop gọi là value. Bất cứ thứ gì bạn đặt vào prop đó sẽ có sẵn cho mọi thành phần con, bất kể chúng nằm sâu đến mức nào.

useContext là Hook cho phép một function component kết nối vào luồng phát sóng đó. Bên trong component của bạn, bạn truyền đối tượng context đã tạo vào useContext, và nó sẽ trả về giá trị hiện tại. Chỉ vậy thôi. Không cần wrapper, không cần thêm props.

Trước khi Hooks ra đời, bạn phải sử dụng pattern Consumer với render props. Nó vẫn hoạt động, nhưng tạo ra rất nhiều khoảng thụt đầu dòng và sự lộn xộn của các wrapper. useContext đã làm phẳng tất cả những thứ đó thành một dòng duy nhất bên trong thân hàm của bạn.

Khi nào thực sự nên dùng Context

Đừng tìm đến Context chỉ vì thói quen. Nó được xây dựng cho những dữ liệu mà nhiều component không liên quan đến nhau cùng chia sẻ qua các nhánh khác nhau của cây component. Các ứng cử viên sáng giá bao gồm:

  • Cài đặt giao diện (Theme settings). Không chỉ là chế độ sáng hay tối, mà còn là các token về khoảng cách (spacing tokens), bảng màu (color palettes) và tỷ lệ phông chữ (font scales). Việc truyền thủ công những thứ này qua mọi button hay modal được định kiểu sẽ rất nhanh chóng gây mệt mỏi.
  • Xác thực người dùng (User authentication). Trạng thái đăng nhập, mảng quyền hạn (permissions array), hoặc đối tượng người dùng hiện tại. Thanh header, một widget dashboard và một route guard riêng tư đều có thể nằm ở các góc khác nhau của cây component.
  • Tùy chọn ngôn ngữ (Language preferences). Chuỗi locale, định dạng ngày tháng và ký hiệu tiền tệ. Các component lá (leaf components) sâu bên trong như nhãn biểu mẫu (form labels) cần những thứ này mà không muốn mọi component cha trên đường dẫn phải biết về chúng.
  • Dữ liệu giỏ hàng (Shopping cart data). Số lượng mặt hàng, tổng giá trị và các hàm thêm vào giỏ hàng. Badge trên header và trang thanh toán cần cùng một trạng thái, nhưng chúng thường nằm dưới các nhánh layout hoàn toàn khác nhau.

Một ví dụ thực tế về Bộ chuyển đổi giao diện (Theme Switcher)

Một trong những cách rõ ràng nhất để thấy Context hoạt động là bộ chuyển đổi giao diện. Dưới đây là cách bạn có thể thiết lập nó mà không bỏ qua các chi tiết thực sự quan trọng.

Đầu tiên, tạo một tệp ThemeContext.js. Gọi React.createContext() và lưu kết quả đó lại. Sau đó xây dựng một component ThemeProvider để quản lý giao diện hiện tại bằng useState hoặc useReducer. Bao bọc các component con trong Provider của context, truyền vào một đối tượng chứa cả giao diện hiện tại và một hàm để chuyển đổi nó. Xuất (export) cả ThemeProvider và chính đối tượng context đó.

Thứ hai, đi tới điểm khởi đầu (entry point) của ứng dụng. Import ThemeProvider và bao bọc toàn bộ ứng dụng của bạn bằng nó. Nếu bạn bỏ qua bước này, bất kỳ thứ gì cố gắng đọc context sau đó sẽ chỉ thấy giá trị mặc định.

Thứ ba, bên trong một component Header hoặc Content, hãy import đối tượng context và useContext. Gọi Hook, destructure theme và hàm chuyển đổi, sau đó áp dụng các class CSS của bạn một cách có điều kiện. Thêm một nút bấm để gọi hàm chuyển đổi. Component này không bao giờ nhận được prop theme từ component cha của nó. Nó lấy tín hiệu trực tiếp từ không trung.

Prop Drilling, Context, hay Redux?

Việc lựa chọn giữa các công cụ này không hẳn là vấn đề về sự trung thành, mà quan trọng hơn là cấu trúc trạng thái (state) của bạn.

Prop drilling hoàn toàn ổn nếu chỉ ở độ sâu hai hoặc ba cấp. Nó tường minh, dễ dàng truy vết trong IDE và giúp các phụ thuộc (dependencies) trở nên rõ ràng. Vấn đề chỉ phát sinh khi bạn bắt đầu truyền cùng một prop qua sáu hoặc bảy lớp.

Context API đi kèm sẵn với React. Điều này có nghĩa là không làm tăng kích thước bundle và không cần thiết lập bên ngoài. Nó xử lý các trạng thái toàn cục quy mô nhỏ đến trung bình một cách tuyệt vời, đặc biệt là những dữ liệu ít thay đổi như giao diện (themes) hoặc hồ sơ người dùng.

Redux yêu cầu cài đặt thêm các thư viện và viết mã boilerplate. Nó sẽ phát huy tác dụng khi logic state của bạn phức tạp, khi nhiều phần (slices) của state tương tác sâu với nhau, hoặc khi bạn cần tính năng time-travel debugging và middleware. Đối với dữ liệu toàn cục đơn giản, Redux là quá mức cần thiết (overkill).

Thực tế về hiệu năng mà không ai nói tới

Đây chính là điểm mấu chốt phân biệt giữa cách triển khai của junior và senior. Khi giá trị của một Context Provider thay đổi, mọi component đang sử dụng context đó đều sẽ re-render. Việc phần dữ liệu cụ thể mà component đó quan tâm có thay đổi hay không không còn quan trọng nữa. React nhận thấy tham chiếu (reference) mới và lập lịch cập nhật.

Nếu bạn đổ toàn bộ trạng thái ứng dụng vào một StoreContext khổng lồ, bạn đã vô tình gắn chặt toàn bộ UI của mình lại với nhau. Việc thay đổi cài đặt giao diện sẽ khiến giỏ hàng, biểu đồ dashboard và danh sách thông báo của bạn đều phải re-render. Đó là một công việc không cần thiết.

Hãy chia nhỏ các context theo từng domain. Giữ một ThemeContext cho các cài đặt hiển thị, một UserContext cho dữ liệu hồ sơ và một CartContext cho trạng thái thương mại. Nếu người dùng chỉnh sửa tên hiển thị, header của bạn sẽ cập nhật mà không ảnh hưởng đến lưới sản phẩm. Ngoài ra, hãy cẩn thận với những gì bạn truyền vào prop value của Provider. Nếu bạn truyền một object literal { theme, toggleTheme } trực tiếp trong quá trình render, bạn sẽ tạo ra một tham chiếu mới sau mỗi lần render và kích hoạt các cập nhật không cần thiết. Hãy ổn định cấu trúc đó bằng useMemo nếu giá trị chứa các hàm hoặc dữ liệu không phải kiểu nguyên thủy (non-primitive).

Những sai lầm gây lãng phí hàng giờ đồng hồ

Có hai lỗi khiến các đội ngũ mắc phải liên tục.

Quên export đối tượng context. Rất dễ xảy ra tình trạng export component ThemeProvider rồi sau đó cố gắng gọi useContext(ThemeProvider). Cách hoạt động không phải như vậy. Hook cần đối tượng context được trả về từ createContext, chứ không phải component bao bọc (wrapper component). Nếu bạn chỉ export Provider, các consumer sẽ không có gì để import.

Gọi useContext bên ngoài Provider của nó. Hook sẽ trả về giá trị mặc định mà bạn đã truyền vào createContext. Nếu bạn không truyền giá trị mặc định, bạn sẽ nhận được undefined. Nếu cây component của bạn render consumer ở vị trí cao hơn Provider trong DOM, hoặc nếu Provider hoàn toàn bị thiếu, dữ liệu của bạn đơn giản là sẽ không đến được. Hãy kiểm tra kỹ xem file index hoặc file root của bạn đã thực sự bao bọc (wrap) ứng dụng hay chưa.

Bài học cốt lõi

React Context không phải là một cuộc cách mạng về quản lý trạng thái. Nó là một công cụ chuyên biệt cho một vấn đề về không gian cụ thể: đưa dữ liệu đến các component ở xa mà không biến mọi lớp trung gian thành một bưu điện. Hãy sử dụng nó cho các dữ liệu thực sự mang tính toàn cục, chia nhỏ các context theo domain để bảo vệ hiệu năng render, và luôn bao bọc cây component của bạn bằng Provider chính xác trước khi cố gắng đọc tín hiệu. Nắm vững những thói quen này, và cây component của bạn sẽ luôn sạch sẽ, nhanh chóng và dễ hiểu.