Compound Components: Bí quyết tạo UI linh hoạt, mạnh mẽ như React chuyên nghiệp

Compound Components: Bí quyết tạo UI linh hoạt, mạnh mẽ như React chuyên nghiệp

Compound Components là gì?

Trong thế giới phát triển giao diện người dùng (UI) hiện đại, đặc biệt với React, việc xây dựng các component có khả năng tái sử dụng cao và linh hoạt là chìa khóa. Tuy nhiên, đôi khi chúng ta gặp phải tình huống các component trở nên quá "cứng nhắc" hoặc đòi hỏi quá nhiều props để tùy chỉnh. Đây chính là lúc Compound Components tỏa sáng.

Hiểu đơn giản, Compound Components là một design pattern mà ở đó, một nhóm các component làm việc cùng nhau để tạo thành một UI widget hoàn chỉnh. Thay vì là một component "đơn độc" nhận mọi props, chúng là một tập hợp các component con được thiết kế để chia sẻ trạng thái (state) và logic ngầm định với component cha, nhưng vẫn cho phép người dùng tự do sắp xếp chúng.

Tại sao bạn cần Compound Components?

Pattern này giải quyết một số vấn đề phổ biến mà các developer thường gặp:

  • Giảm Prop Drilling: Khi bạn có một cấu trúc component lồng nhau sâu, việc truyền props (chẳng hạn như isActive hay onClick) qua nhiều tầng component con có thể trở nên phức tạp và khó bảo trì. Compound Components giúp các component con truy cập thông tin chung mà không cần truyền props tường minh qua từng cấp.
  • Tạo API linh hoạt và khai báo (Declarative API): Thay vì phải truyền vô số props cho một component cha để điều khiển cấu trúc bên trong (ví dụ: một component Tabs nhận mảng items), bạn có thể định nghĩa các component con một cách rõ ràng. Điều này cho phép người dùng kiểm soát trực tiếp cấu trúc HTML và render của chúng, mang lại sự linh hoạt tối đa.
  • Tách biệt mối quan tâm (Separation of Concerns): Mỗi component con có một trách nhiệm cụ thể, làm cho code dễ hiểu, dễ kiểm thử và dễ bảo trì hơn. Component cha tập trung vào việc quản lý trạng thái, còn các component con tập trung vào việc hiển thị và tương tác.

Chúng hoạt động như thế nào trong React?

Compound Components thường được triển khai trong React thông qua hai kỹ thuật chính:

  • React Context API: Đây là công cụ mạnh mẽ nhất để chia sẻ state và các hàm xử lý (handler functions) giữa component cha và các component con mà không cần truyền prop tường minh. Component cha sẽ cung cấp Context, và các component con sẽ tiêu thụ Context đó để lấy thông tin cần thiết.
  • React.ChildrenReact.cloneElement: Đôi khi, component cha cần duyệt qua các component con của nó (sử dụng React.Children.map) và "inject" thêm props vào chúng (sử dụng React.cloneElement) để chúng có thể hoạt động đúng cách. Kỹ thuật này ít phổ biến hơn Context cho việc chia sẻ state, nhưng hữu ích cho việc tùy chỉnh hành vi hoặc props của con dựa trên cha.

Lợi ích cho người dùng library của bạn

Khi bạn xây dựng một thư viện UI sử dụng Compound Components, người dùng của bạn sẽ nhận được những giá trị đáng kể:

  • Linh hoạt vượt trội: Người dùng không bị ràng buộc bởi một cấu trúc cố định. Họ có thể sắp xếp các component con theo bất kỳ thứ tự nào, chèn các element HTML tùy chỉnh hoặc các component khác vào giữa các component con của bạn. Ví dụ, họ có thể đặt một nút "Thêm mới" ngay giữa hai tab.
  • API dễ hiểu, dễ dùng: Cung cấp một API tự mô tả. Thay vì phải nhớ tên hàng tá props của một component duy nhất, người dùng chỉ cần nhìn vào tên các component con (ví dụ: <Tabs.TabList>, <Tabs.Tab>) là có thể hình dung được cách sử dụng.
  • Kiểm soát giao diện tối đa: Người dùng có toàn quyền kiểm soát việc render và cấu trúc của UI widget. Họ có thể truyền props riêng vào từng component con, thay đổi thứ tự, hoặc thậm chí là không render một phần nào đó nếu không cần.
  • Khả năng tái sử dụng cao: Các component con có thể được thiết kế để có một số khả năng độc lập nhất định, cho phép chúng được tái sử dụng trong các ngữ cảnh khác nhau hoặc kết hợp theo những cách sáng tạo.

Ví dụ thực tế: Component Tabs

Hãy xem xét một component Tabs. Nếu không dùng Compound Components, bạn có thể phải truyền một mảng các đối tượng tab:

<Tabs  items={[    { label: "Tab 1", content: "Nội dung của Tab 1" },    { label: "Tab 2", content: "Nội dung của Tab 2" },  ]}/>

Cách này đơn giản nhưng hạn chế. Bạn không thể chèn một icon đặc biệt vào một tab cụ thể, hay đặt một nút bấm giữa các tab. Với Compound Components, mọi thứ trở nên linh hoạt hơn nhiều:

// Định nghĩa một component cha và các component con// (Chi tiết triển khai bên trong sẽ dùng Context để chia sẻ state 'activeTabId' và hàm 'setActiveTabId')const Tabs = ({ children }) => { /* ... */ };Tabs.TabList = ({ children }) => { /* ... */ };Tabs.Tab = ({ id, children }) => { /* ... */ };Tabs.Panels = ({ children }) => { /* ... */ };Tabs.Panel = ({ id, children }) => { /* ... */ };// Cách sử dụng linh hoạt<Tabs>  <Tabs.TabList>    <Tabs.Tab id="tab1"><strong>Tab 1</strong></Tabs.Tab>    <span style={{ margin: "0 10px" }}>|</span> <!-- Chèn element tùy chỉnh! -->    <Tabs.Tab id="tab2">Tab 2</Tabs.Tab>  </Tabs.TabList>  <Tabs.Panels>    <Tabs.Panel id="tab1">      <p>Đây là nội dung <em>phong phú</em> của Tab 1.</p>    </Tabs.Panel>    <Tabs.Panel id="tab2">      <p>Nội dung của Tab 2.</p>    </Tabs.Panel></Tabs>

Trong ví dụ trên, Tabs.TabTabs.Panel không cần biết về nhau một cách trực tiếp. Chúng chỉ cần truy cập Context được cung cấp bởi component Tabs cha để biết tab nào đang hoạt động và hiển thị nội dung phù hợp. Điều này mang lại sự tự do cực lớn cho người dùng thư viện, cho phép họ tạo ra giao diện phức tạp mà không bị gò bó.

Lời kết

Compound Components không chỉ là một "chiêu trò" mà là một design pattern mạnh mẽ, giúp bạn xây dựng các UI component linh hoạt, dễ bảo trì và có API thân thiện với developer. Bằng cách áp dụng pattern này, bạn không chỉ nâng cao chất lượng code mà còn mang lại trải nghiệm tuyệt vời cho những ai sử dụng thư viện của bạn. Hãy thử áp dụng nó vào dự án tiếp theo của bạn để thấy sự khác biệt nhé!