Mục lục
Xem mục lục
Compound Components là gì
Compound Components là pattern trong đó nhiều component phối hợp với nhau để tạo thành một UI hoàn chỉnh, chia sẻ state ngầm với nhau mà không cần truyền props tường minh qua từng tầng.
Ý tưởng lấy cảm hứng từ cách các thẻ HTML thuần phối hợp sẵn với nhau, ví dụ <select> và <option>:
<select>
<option value="a">A</option>
<option value="b">B</option>
</select>
<select> và <option> phối hợp ngầm với nhau: <select> biết option nào đang được chọn, <option> biết mình thuộc <select> nào, mà không cần truyền tường minh giá trị nào qua lại. Compound Components trong React mô phỏng lại đúng cơ chế phối hợp ngầm này.
Vấn đề của cách tiếp cận truyền thống: props drilling qua nhiều tầng
Cách viết một component Tabs theo kiểu truyền thống thường dồn toàn bộ cấu hình vào một props duy nhất:
<Tabs
items={[
{ label: "Tab 1", content: <div>Nội dung 1</div> },
{ label: "Tab 2", content: <div>Nội dung 2</div> },
]}
/>
Cách này hoạt động, nhưng khó tùy biến từng phần: muốn thêm icon vào một tab cụ thể, muốn đổi thứ tự hiển thị giữa danh sách tab và nội dung, hoặc muốn chèn thêm một phần tử khác (ví dụ nút “Thêm tab mới”) xen giữa, đều phải sửa lại API của Tabs để hỗ trợ thêm props mới. Cấu hình càng phức tạp, props càng phình to và khó kiểm soát.
Triển khai bằng Context
Compound Components dùng Context để component cha chia sẻ state (ví dụ tab nào đang active) cho các component con, không cần truyền props thủ công qua từng tầng:
const TabsContext = createContext(null);
function Tabs({ children, value, defaultTab, onChange }) {
const [internalTab, setInternalTab] = useState(defaultTab);
const isControlled = value !== undefined;
const activeTab = isControlled ? value : internalTab;
const setActiveTab = id => {
if (!isControlled) {
setInternalTab(id);
}
onChange?.(id);
};
return (
<TabsContext.Provider value={{ activeTab, setActiveTab }}>
{children}
</TabsContext.Provider>
);
}
Các component con đọc trực tiếp từ TabsContext thay vì nhận qua props, nên Tabs không cần biết trước cấu trúc bên trong children là gì.
value/onChange theo đúng pattern controlled input của React: không truyền value thì Tabs tự quản lý state (uncontrolled), truyền value thì component cha nắm toàn quyền, Tabs chỉ gọi onChange để báo thay đổi.
Ví dụ: xây dựng component Tabs
function TabList({ children }) {
return <div className="tab-list">{children}</div>;
}
function Tab({ id, children }) {
const { activeTab, setActiveTab } = useContext(TabsContext);
return (
<button
className={activeTab === id ? "active" : ""}
onClick={() => setActiveTab(id)}
>
{children}
</button>
);
}
function TabPanel({ id, children }) {
const { activeTab } = useContext(TabsContext);
if (activeTab !== id) return null;
return <div className="tab-panel">{children}</div>;
}
Sử dụng:
<Tabs defaultTab="a">
<Tabs.List>
<Tabs.Tab id="a">Tab A</Tabs.Tab>
<Tabs.Tab id="b">Tab B</Tabs.Tab>
</Tabs.List>
<Tabs.Panel id="a">Nội dung A</Tabs.Panel>
<Tabs.Panel id="b">Nội dung B</Tabs.Panel>
</Tabs>
Tab và TabPanel không nhận activeTab qua props, mà tự đọc từ TabsContext. Tabs cũng không cần biết trước sẽ có bao nhiêu tab hay chúng được sắp xếp thế nào.
Static Properties, cách gắn component con vào component cha
Để có được cú pháp gọn <Tabs.Tab> thay vì phải import riêng từng component (TabList, Tab, TabPanel), gắn chúng làm static property của Tabs:
Tabs.List = TabList;
Tabs.Tab = Tab;
Tabs.Panel = TabPanel;
Cách này chỉ là cú pháp gọn hơn, không thay đổi bản chất pattern: người dùng Tabs chỉ cần import một component duy nhất, các component con luôn đi kèm sẵn, và namespace Tabs.* giúp rõ ràng chúng thuộc về nhau.
Lợi ích so với props drilling
- API linh hoạt hơn: người dùng component tự do sắp xếp thứ tự
Tabs.ListvàTabs.Panel, hoặc chèn thêm phần tử khác (ví dụ một nút bên cạnh danh sách tab) mà không cầnTabshỗ trợ thêm props riêng cho việc đó. - Component cha đơn giản hơn:
Tabschỉ quản lý state chung (activeTab), không cần biết chi tiết cấu trúc hiển thị bên trong. - Dễ đọc hơn ở nơi sử dụng: cấu trúc JSX phản ánh trực tiếp cấu trúc UI thật, thay vì phải tra cứu shape của một object cấu hình lớn.
Đánh đổi cần lưu ý
- Component con bắt buộc phải nằm trong component cha đúng context: dùng
Tabs.Tabbên ngoàiTabssẽ đọcuseContextranull, dễ gây lỗi khó hiểu nếu không xử lý rõ ràng. Nên throw lỗi tường minh trong component con khi context lànull, thay vì để lỗi runtime mơ hồ:
function useTabsContext() {
const context = useContext(TabsContext);
if (!context) {
throw new Error("Tabs.Tab phải được dùng bên trong Tabs");
}
return context;
}
- Khó tách component con ra dùng độc lập: vì các component con phụ thuộc vào context của component cha cụ thể, không tái sử dụng được ở nơi khác như một component độc lập thông thường.
- Re-render: mọi component con đọc từ cùng một context sẽ re-render khi giá trị context thay đổi, cần cân nhắc
useMemocho giá trị truyền vàoProvidernếu số lượng component con lớn.
Tổng kết
- Compound Components là các component phối hợp với nhau, chia sẻ state ngầm qua Context, giống cách
<select>/<option>phối hợp trong HTML. - Pattern này giải quyết vấn đề props drilling và cấu hình cứng nhắc của cách truyền props qua một object lớn.
- Triển khai dựa trên
Context.Providerở component cha, các component con tự đọc state quauseContext. - Static Properties (
Tabs.Tab = Tab) chỉ là cú pháp gọn hơn để nhóm các component con lại với component cha. - Đánh đổi: component con phụ thuộc chặt vào component cha, khó tái sử dụng độc lập, cần validate rõ ràng khi dùng sai ngữ cảnh.