Chào mừng các bạn quay trở lại với chuỗi bài viết “React Roadmap”! Trong các bài trước, chúng ta đã cùng nhau khám phá React là gì, sự chuyển mình từ Class sang Functional Components, cách JSX giúp chúng ta mô tả UI, và làm chủ Props vs State cũng như Conditional Rendering. Hôm nay, chúng ta sẽ đi sâu vào một trong những nguyên lý cốt lõi và mạnh mẽ nhất của React, giúp bạn xây dựng giao diện người dùng phức tạp từ những mảnh ghép nhỏ, có thể tái sử dụng: Component Composition.
Nếu coi việc phát triển ứng dụng React như việc xây nhà, thì component chính là những viên gạch. Và Composition (Kết hợp) là cách chúng ta ghép những viên gạch đó lại với nhau một cách khéo léo để tạo nên một công trình vững chắc, đẹp đẽ và dễ dàng mở rộng. Thay vì tạo ra những viên gạch “thông minh” có sẵn mọi thứ (tương tự như kế thừa trong lập trình hướng đối tượng truyền thống), React khuyến khích chúng ta tạo ra những viên gạch đơn giản, chuyên biệt và sau đó “kết hợp” chúng lại.
Hãy cùng tìm hiểu tại sao Component Composition lại quan trọng và những cách phổ biến để áp dụng nó trong ứng dụng React của bạn!
Mục lục
Tại sao không dùng Kế thừa (Inheritance) trong Component?
Trong lập trình hướng đối tượng truyền thống, kế thừa (`extends`) là một cách phổ biến để tái sử dụng code. Bạn có một lớp cha với các thuộc tính và phương thức chung, sau đó các lớp con kế thừa và mở rộng nó.
Tuy nhiên, trong thế giới UI với React components, kế thừa thường không phải là cách tốt nhất để tái sử dụng code. Có một số lý do chính:
- Tight Coupling (Gắn kết chặt chẽ): Component con bị ràng buộc chặt chẽ với component cha. Bất kỳ thay đổi nào trong component cha có thể ảnh hưởng không mong muốn đến component con.
- Fragility (Dễ vỡ): Khó thêm các tính năng mới hoặc thay đổi hành vi ở giữa chuỗi kế thừa mà không phá vỡ các component con.
- Limited Reusability (Hạn chế tái sử dụng): Một component chỉ có thể kế thừa từ một component khác. Nếu bạn muốn một component có hành vi từ nhiều nguồn khác nhau, kế thừa trở nên phức tạp hoặc không thể.
- Solving the Wrong Problem: Kế thừa tốt cho việc mô tả quan hệ “là một loại” (Is-A). Ví dụ: `Cat` là một loại `Animal`. Nhưng trong UI, chúng ta thường cần mô tả quan hệ “có chứa” (Has-A) hoặc “sử dụng” (Uses-A). Ví dụ: Một `UserProfile` có chứa `Avatar` và `Username`. Đây là nơi Composition tỏa sáng.
React team và cộng đồng khuyến khích sử dụng Composition thay vì Inheritance cho việc tái sử dụng code giữa các component.
Sức mạnh của `props.children`: Nền tảng của Composition
Hình thức kết hợp (composition) đơn giản và phổ biến nhất trong React chính là thông qua thuộc tính đặc biệt props.children
. Khi bạn đặt các phần tử (elements) hoặc component khác vào giữa thẻ mở và thẻ đóng của một component JSX, React sẽ truyền những phần tử đó vào component cha dưới dạng thuộc tính children
trong đối tượng props
.
Đây được gọi là mẫu Containment (Chứa đựng).
Ví dụ điển hình là một component dùng để tạo khung viền hoặc bố cục:
function FancyBorder(props) {
// props.children sẽ là bất kỳ thứ gì được đặt bên trong <FancyBorder>...</FancyBorder>
return (
<div className={'fancy-border fancy-border-' + props.color}>
{/* Render nội dung con ở đây */}
{props.children}
</div>
);
}
// Sử dụng component FancyBorder
function WelcomeDialog() {
return (
<FancyBorder color="blue">
{/* Đây là nội dung con, sẽ trở thành props.children của FancyBorder */}
<h1 className="Dialog-title">
Chào mừng
</h1>
<p className="Dialog-message">
Cảm ơn bạn đã ghé thăm hành tinh của chúng tôi!
</p>
</FancyBorder>
);
}
// Output (đại diện)
// <div className="fancy-border fancy-border-blue">
// <h1 className="Dialog-title">
// Chào mừng
// </h1>
// <p className="Dialog-message">
// Cảm ơn bạn đã ghé thăm hành tinh của chúng tôi!
// </p>
// </div>
Trong ví dụ trên, component FancyBorder
không biết trước nội dung bên trong nó sẽ là gì. Nó chỉ đơn giản là hiển thị bất cứ thứ gì được truyền vào props.children
. Điều này làm cho FancyBorder
trở nên rất linh hoạt và có thể tái sử dụng cho nhiều mục đích khác nhau (khung dialog, panel, card, v.v.).
props.children
có thể là bất cứ thứ gì mà React có thể render: một React element, một mảng các elements, một chuỗi, một số, hoặc thậm chí là null
, undefined
, hoặc boolean
(những giá trị này sẽ không được render). Nó là cách hiệu quả để “đục một lỗ” trong component cha và cho phép component bên ngoài điền nội dung vào đó.
Các Mẫu Kết hợp Component Phổ biến
Ngoài props.children
, có nhiều cách khác để kết hợp các component, mỗi cách phù hợp với những bài toán khác nhau.
Kết hợp thông qua Props (Passing Elements as Props)
Thay vì chỉ sử dụng props.children
để chèn một khối nội dung chính, bạn có thể truyền các React element khác vào component dưới dạng các prop thông thường. Điều này hữu ích khi bạn muốn định nghĩa nhiều “khu vực” (slots) trong component cha để nhận nội dung từ bên ngoài.
Ví dụ về một component bố cục trang:
function PageLayout(props) {
return (
<div className="page-layout">
{/* Khu vực header */}
<header className="page-header">{props.header}</header>
{/* Khu vực nội dung chính (vẫn có thể dùng children) */}
<main className="page-main">{props.children}</main>
{/* Khu vực footer */}
<footer className="page-footer">{props.footer}</footer>
</div>
);
}
// Cách sử dụng component PageLayout
function MyApp() {
const currentUser = { name: 'John Doe' }; // Giả định dữ liệu người dùng
// Tạo các element/component cho từng khu vực
const pageHeader = (
<div>
<h1>Tiêu đề Trang của Tôi</h1>
<p>Chào mừng, {currentUser.name}!</p>
</div>
);
const pageFooter = (
<div>
<p>Bản quyền © 2023 Công ty ABC.</p>
<a href="/privacy">Chính sách Bảo mật</a>
</div>
);
return (
<PageLayout
header={pageHeader} // Truyền element vào prop 'header'
footer={pageFooter} // Truyền element vào prop 'footer'
>
{/* Nội dung chính của trang, sẽ trở thành props.children */}
<section>
<h2>Đây là phần nội dung chính</h2>
<p>Các đoạn văn, hình ảnh và các component khác đi vào đây...</p>
{/* Ví dụ về sử dụng component khác bên trong */}
<SomeOtherComponent data={...} />
</section>
<section>
<h2>Một phần khác</h2>
<p>Thêm nội dung...</p>
</section>
</PageLayout>
);
}
Kỹ thuật này cho phép component PageLayout
định nghĩa cấu trúc chung của trang, nhưng giao diện cụ thể cho từng phần (header, footer, main content) lại do component cha (MyApp
) cung cấp. Điều này rất linh hoạt và dễ bảo trì. Component cha quyết định nội dung sẽ là gì, trong khi component con (PageLayout
) quyết định nó sẽ hiển thị ở đâu.
Kỹ thuật truyền dữ liệu và element qua props
là một khái niệm cơ bản trong React. Nếu bạn chưa chắc chắn về cách hoạt động của props
, hãy xem lại bài viết trước của chúng ta về React Props vs State: Ai Kiểm Soát Dữ Liệu Gì?.
Higher-Order Components (HOCs)
HOC không phải là một component theo nghĩa truyền thống, mà là một hàm nhận vào một component và trả về một component mới với các tính năng bổ sung (enhanced). Mục đích chính của HOC là tái sử dụng logic chung giữa nhiều component.
Ví dụ phổ biến là HOC để hiển thị trạng thái tải (loading):
import React from 'react';
// Hàm HOC
function withLoading(WrappedComponent) {
// Trả về một component Functional mới
return function WithLoadingComponent({ isLoading, ...props }) {
if (isLoading) {
return <p>Đang tải dữ liệu...</p>;
}
// Truyền tất cả các props còn lại xuống WrappedComponent
return <WrappedComponent {...props} />;
};
}
// Một component cần hiển thị dữ liệu (sẽ được bọc bởi HOC)
function MyDataDisplay({ data }) {
if (!data) {
return <p>Không có dữ liệu.</p>;
}
return (
<div>
<h3>Dữ liệu đã tải:</h3>
<p>{data}</p>
</div>
);
}
// Áp dụng HOC để tạo component mới có tính năng loading
const MyDataDisplayWithLoading = withLoading(MyDataDisplay);
// Cách sử dụng component mới trong ứng dụng của bạn
function App() {
const [loading, setLoading] = React.useState(true);
const [data, setData] = React.useState(null);
React.useEffect(() => {
// Giả lập việc tải dữ liệu
setTimeout(() => {
setData("Đây là dữ liệu từ API.");
setLoading(false);
}, 2000);
}, []);
return (
<div>
<h2>Ứng dụng sử dụng HOC</h2>
{/* Component này giờ đây nhận thêm prop 'isLoading' */}
<MyDataDisplayWithLoading isLoading={loading} data={data} />
</div>
);
}
HOC giúp bạn tránh việc lặp lại cùng một logic (ví dụ: kiểm tra trạng thái loading, xử lý subscribe/unsubscribe) trong nhiều component khác nhau. Tuy nhiên, HOC cũng có nhược điểm như khó theo dõi nguồn gốc của props (prop collision), khó debug do có lớp wrapper, và có thể dẫn đến “wrapper hell” khi nhiều HOC được lồng vào nhau.
Render Props
Mẫu Render Props sử dụng một prop có giá trị là một hàm. Component con gọi hàm này và truyền cho nó dữ liệu mà nó cần để component cha có thể “render” (hiển thị) phần giao diện dựa trên dữ liệu đó.
Mục đích chính của Render Props cũng là chia sẻ logic (đặc biệt là logic liên quan đến state) giữa các component, tương tự HOC, nhưng với cách tiếp cận khác.
Ví dụ về component theo dõi vị trí chuột:
import React from 'react';
// Component sử dụng Render Props
class Mouse extends React.Component {
constructor(props) {
super(props);
this.state = { x: 0, y: 0 };
}
handleMouseMove = (event) => {
this.setState({
x: event.clientX,
y: event.clientY
});
};
render() {
// props.render LÀ MỘT HÀM!
// Component Mouse gọi hàm này và truyền state hiện tại của nó.
return (
<div style={{ height: '50vh', border: '1px dashed gray' }} onMouseMove={this.handleMouseMove}>
{/* Gọi hàm render prop, truyền dữ liệu state */}
{this.props.render(this.state)}
</div>
);
}
}
// Cách sử dụng component Mouse với Render Props
function App() {
return (
<div>
<h2>Ứng dụng sử dụng Render Props</h2>
{/* Sử dụng Mouse để hiển thị vị trí chuột bằng văn bản */}
<Mouse render={({ x, y }) => (
<p>Vị trí chuột hiện tại: ({x}, {y})</p>
)}/>
{/* Sử dụng Mouse để di chuyển một hình ảnh theo chuột */}
<Mouse render={({ x, y }) => (
<img
src="https://reactjs.org/logo-og.png" // Thay bằng URL hình ảnh của bạn
style={{ position: 'absolute', left: x - 20, top: y - 20, width: 40, height: 40 }}
alt="Chuột di chuyển"
/>
)}/>
</div>
);
}
Trong ví dụ này, component Mouse
chứa logic theo dõi vị trí chuột. Nó không quan tâm việc hiển thị vị trí đó như thế nào. Việc hiển thị được giao cho hàm trong prop render
do component cha (App
) cung cấp. Component cha nhận dữ liệu vị trí chuột từ Mouse
và quyết định giao diện. Điều này giúp tái sử dụng logic theo dõi chuột độc lập với việc hiển thị UI.
Render Props giải quyết một số vấn đề của HOC (như “wrapper hell”) nhưng cú pháp có thể hơi khó đọc ban đầu. Nó cũng đòi hỏi component con phải là một component class nếu cần quản lý state nội bộ theo cách truyền thống, hoặc sử dụng Hooks bên trong functional component.
Custom Hooks (Kết hợp logic)
Sự ra đời của Hooks trong React 16.8 đã cách mạng hóa cách chúng ta tái sử dụng logic stateful. Custom Hooks không phải là một mẫu kết hợp component theo nghĩa hiển thị UI, nhưng chúng là mẫu kết hợp logic hiệu quả nhất hiện nay.
Custom Hooks là các hàm JavaScript thông thường mà tên của chúng bắt đầu bằng `use` và chúng có thể gọi các Hooks khác (như useState
, useEffect
, useContext
, v.v.). Chúng cho phép bạn trích xuất logic stateful từ component và chia sẻ nó giữa các component functional.
Ví dụ về một custom hook đơn giản để quản lý trạng thái bật/tắt:
import { useState, useCallback } from 'react';
// Custom Hook
function useToggle(initialValue = false) {
const [value, setValue] = useState(initialValue);
// Sử dụng useCallback để tránh tạo lại hàm toggle mỗi lần render
const toggle = useCallback(() => {
setValue(currentValue => !currentValue);
}, []); // dependencies array rỗng vì không phụ thuộc vào biến nào thay đổi
// Trả về trạng thái hiện tại và hàm để đổi trạng thái
return [value, toggle];
}
// Sử dụng Custom Hook trong component functional
function ToggleButton() {
// Rất gọn gàng! Logic bật/tắt đã được tái sử dụng từ useToggle
const [isOn, toggle] = useToggle(false);
return (
<button onClick={toggle}>
Trạng thái: {isOn ? <strong>Bật</strong> : <strong>Tắt</strong>}
</button>
);
}
function AnotherComponent() {
// Component khác cũng có thể sử dụng lại cùng logic
const [isVisible, toggleVisibility] = useToggle(true);
return (
<div>
<button onClick={toggleVisibility}>
{isVisible ? 'Ẩn nội dung' : 'Hiện nội dung'}
</button>
{/* Sử dụng conditional rendering dựa trên state từ hook */}
{isVisible && <p>Nội dung này có thể ẩn/hiện.</p>}
</div>
);
}
// Component App chứa cả hai
function App() {
return (
<div>
<h2>Ứng dụng sử dụng Custom Hooks</h2>
<ToggleButton />
<br /><br /> {/* Thêm khoảng cách */}
<AnotherComponent />
</div>
);
}
Custom Hooks cho phép bạn chia sẻ logic (như quản lý state, side effects, subscriptions, v.v.) một cách sạch sẽ và dễ hiểu giữa các component functional. Chúng đã trở thành phương pháp được khuyến khích nhất để tái sử dụng logic stateful trong React hiện đại, thường thay thế nhu cầu sử dụng HOCs và Render Props cho mục đích này. Đây là một lý do lớn tại sao Functional Components trở nên phổ biến hơn Class Components.
Khi Nào Sử Dụng Mẫu Kết hợp Nào?
Việc lựa chọn mẫu kết hợp phù hợp phụ thuộc vào bài toán bạn đang giải quyết. Dưới đây là bảng tóm tắt giúp bạn đưa ra quyết định:
Mẫu | Mục đích chính | Ưu điểm | Nhược điểm | Mức độ phổ biến (Hiện tại) |
---|---|---|---|---|
props.children (Containment) |
Chèn nội dung bất kỳ vào bên trong component cha; component cha không cần biết trước nội dung là gì. | Đơn giản, dễ hiểu, cú pháp JSX tự nhiên, lý tưởng cho các component “khung” (layout, panel, card). | Chỉ chèn được một “khối” nội dung chính (mặc dù có thể render mảng children). Khó đặt tên cho các “slot” khác nhau. | Rất phổ biến, nền tảng của nhiều component UI. |
Explicit Props (Truyền element qua prop) | Chèn các khối nội dung cụ thể vào các vị trí định trước trong component cha. Component cha định nghĩa cấu trúc, component cha/ông/bà cung cấp nội dung. | Linh hoạt hơn children, đặt tên rõ ràng (ví dụ: props.header , props.footer ), lý tưởng cho các component bố cục phức tạp hơn. |
Có thể hơi dài dòng nếu có quá nhiều “slot”. | Phổ biến, đặc biệt cho các component layout hoặc container tùy chỉnh. |
Higher-Order Components (HOCs) | Chia sẻ logic (state, behavior, side effects) giữa nhiều component class hoặc functional trước khi Hooks phổ biến. | Tái sử dụng logic hiệu quả. | Có thể tạo ra “wrapper hell”, khó debug prop, xung đột tên prop, khó kết hợp nhiều HOC. | Ít phổ biến hơn trong code mới sử dụng Hooks, nhưng vẫn tồn tại trong code cũ và một số thư viện. Nên cân nhắc chuyển sang Custom Hooks nếu có thể. |
Render Props | Chia sẻ logic (state, behavior) và cho phép component cha kiểm soát việc render dựa trên dữ liệu của component con. Thường dùng để chia sẻ logic liên quan đến state nội bộ hoặc DOM events. | Rất linh hoạt trong việc render (component cha kiểm soát hoàn toàn), tránh “wrapper hell” của HOCs. | Cú pháp có thể hơi khó đọc (nested callbacks), “callback hell” nếu lồng nhiều Render Props. Có thể ảnh hưởng đến hiệu suất nếu không cẩn thận (hàm prop được tạo mới mỗi lần render). | Ít phổ biến hơn trong code mới sử dụng Hooks, nhưng vẫn là một mẫu mạnh mẽ và đáng hiểu. Hooks thường thay thế cho mục đích chia sẻ logic. |
Custom Hooks | Chia sẻ stateful logic và side effects giữa các component functional. | Cách hiện đại, gọn gàng và mạnh mẽ nhất để tái sử dụng logic stateful. Dễ hiểu, dễ test, tránh các vấn đề của HOC/Render Props. Hoạt động tốt với Functional Components. | Chỉ dùng được trong Functional Components hoặc custom Hooks khác. | Rất phổ biến, là phương pháp khuyến khích sử dụng cho việc tái sử dụng logic trong React hiện đại. |
Nhìn vào bảng trên, bạn có thể thấy rằng props.children
và Explicit Props là các kỹ thuật chủ yếu để kết hợp các React elements/components trực quan (chia sẻ cấu trúc UI), trong khi HOC, Render Props và Custom Hooks chủ yếu dùng để kết hợp và tái sử dụng logic. Trong React hiện đại, Custom Hooks là lựa chọn hàng đầu cho việc tái sử dụng logic stateful trong Functional Components.
Lợi ích của Component Composition
Áp dụng Component Composition mang lại nhiều lợi ích quan trọng:
- Tái sử dụng (Reusability): Dễ dàng tạo ra các component nhỏ, chuyên biệt và sử dụng chúng ở nhiều nơi khác nhau trong ứng dụng.
- Linh hoạt (Flexibility): Các component có thể được kết hợp theo nhiều cách khác nhau để tạo ra các giao diện phức tạp mà không cần sửa đổi bản thân các component đó.
- Bảo trì (Maintainability): Code trở nên dễ hiểu và dễ quản lý hơn vì mỗi component chỉ làm một việc cụ thể và được kết hợp với nhau một cách rõ ràng.
- Testability (Dễ kiểm thử): Các component nhỏ, độc lập dễ viết unit test hơn.
- Phân tách quan tâm (Separation of Concerns): Mỗi component tập trung vào một khía cạnh cụ thể (ví dụ: một component chỉ xử lý logic form, một component khác chỉ xử lý hiển thị layout, một component khác nữa chỉ hiển thị dữ liệu).
Composition, cùng với việc hiểu về JSX và cách quản lý dữ liệu bằng Props và State, là nền tảng để bạn xây dựng các ứng dụng React mạnh mẽ. Nó cho phép bạn nghĩ về UI như một hệ thống phân cấp các component lồng nhau, nơi component cha “sở hữu” và quản lý các component con, truyền dữ liệu và cấu hình cho chúng thông qua props.
Các kỹ thuật như Conditional Rendering mà chúng ta đã học cũng thường được sử dụng bên trong các component được tạo ra thông qua composition, ví dụ như hiển thị component loading (như trong ví dụ HOC) hoặc ẩn/hiện nội dung (như trong ví dụ Custom Hook).
Lời kết
Component Composition là xương sống của React. Nắm vững các mẫu kết hợp này sẽ giúp bạn viết code React hiệu quả, dễ bảo trì và có khả năng mở rộng cao. Hãy luyện tập bằng cách chia nhỏ giao diện của bạn thành các component nhỏ nhất có thể và sau đó kết hợp chúng lại bằng props.children
, truyền props, và Custom Hooks.
Việc chuyển từ tư duy kế thừa sang tư duy kết hợp có thể cần thời gian, nhưng đó là một bước quan trọng trên con đường trở thành một developer React giỏi. Hãy nhớ lại bài viết React Là Gì? và lý do nó phổ biến – triết lý component và cách kết hợp chúng chính là câu trả lời lớn nhất.
Trong bài viết tiếp theo của chuỗi “React Roadmap”, chúng ta sẽ tìm hiểu sâu hơn về State Management – cách quản lý dữ liệu phức tạp trong các ứng dụng quy mô lớn hơn. Đừng bỏ lỡ nhé!
Chúc bạn học tốt và code vui vẻ!