Bài 18.4: Render props pattern trong TypeScript

Chào mừng bạn đến với hành trình khám phá các mẫu thiết kế (design patterns) trong React, và hôm nay chúng ta sẽ cùng nhau "giải phẫu" một trong những mẫu cực kỳ linh hoạt để chia sẻ logic giữa các component: Render props pattern. Kết hợp với sức mạnh của TypeScript, chúng ta sẽ thấy nó trở nên mạnh mẽ và đáng tin cậy như thế nào.

Render Props là gì?

Đơn giản mà nói, Render props pattern là một kỹ thuật trong React để chia sẻ code giữa các component bằng cách sử dụng một prop có giá trị là một hàm. Component nhận prop này sẽ gọi hàm đó, truyền dữ liệu hoặc state của nó xuống, và hàm đó sẽ trả về phần tử React (JSX) để component đó render.

Tên "render props" xuất phát từ việc prop phổ biến nhất được sử dụng thường có tên là render, nhưng bạn hoàn toàn có thể đặt tên khác, thậm chí sử dụng prop children (khi đó pattern này còn được gọi là "Function as a Child").

Mục tiêu chính? Tái sử dụng logic quản lý state hoặc hành vi, mà không bị ràng buộc bởi cách component đó trông như thế nào. Component cung cấp logic (gọi là Provider Component) không cần biết cách component tiêu thụ (Consumer Component) sẽ hiển thị dữ liệu đó; nó chỉ cung cấp dữ liệu. Ngược lại, Consumer Component nhận dữ liệu và có toàn quyền quyết định cách render dựa trên dữ liệu đó.

Tại sao lại cần Render Props?

Trong React, có nhiều cách để chia sẻ logic: Composition, Higher-Order Components (HOCs), Custom Hooks. Render props là một lựa chọn tuyệt vời khi bạn muốn:

  1. Chia sẻ logic stateful: Component A quản lý một state (ví dụ: vị trí chuột, trạng thái bật/tắt, dữ liệu fetch về). Component B, C, D đều cần truy cập và render dựa trên state đó, nhưng mỗi component lại muốn hiển thị state theo cách riêng của mình.
  2. Cực kỳ linh hoạt trong UI: Provider Component không "nhúng" sẵn UI cho state đó. Nó "phơi bày" state thông qua hàm render, cho phép Consumer Component tự do xây dựng UI hoàn toàn.
  3. Tránh "Wrapper Hell" của HOCs: Đôi khi sử dụng nhiều HOCs lồng nhau có thể làm cho cây component trong React DevTools khó đọc. Render props thường tạo ra cây component phẳng hơn.

Render Props với TypeScript: Thêm Lớp Bảo Vệ

Đây là lúc TypeScript tỏa sáng! Khi sử dụng Render props, chúng ta truyền một hàm. Hàm này có các tham số đầu vào (dữ liệu từ Provider Component) và trả về đầu ra (thường là React.ReactNode hoặc JSX.Element). TypeScript giúp chúng ta định nghĩa chính xác kiểu dữ liệu của các tham số này và kiểu trả về của hàm render.

Điều này mang lại sự an toàn và rõ ràng:

  • An toàn: Trình biên dịch sẽ báo lỗi nếu bạn cố gắng sử dụng dữ liệu từ hàm render với kiểu không chính xác.
  • Rõ ràng: Khi nhìn vào định nghĩa kiểu của prop render, bạn biết ngay Provider Component cung cấp những dữ liệu gì cho bạn sử dụng.

Hãy đi vào ví dụ cụ thể.

Ví dụ 1: Theo dõi vị trí chuột (Mouse Tracker)

Đây là một ví dụ kinh điển để minh họa Render props. Chúng ta muốn một component quản lý vị trí (x, y) của chuột, nhưng không render bất kỳ UI nào liên quan đến chuột cả. Thay vào đó, nó sẽ truyền xy cho hàm render để component khác sử dụng.

Đầu tiên, định nghĩa kiểu dữ liệu với TypeScript:

// mouse-tracker.types.ts
import * as React from 'react';

interface MousePosition {
  x: number;
  y: number;
}

interface MouseTrackerProps {
  /**
   * Hàm này nhận vị trí chuột hiện tại và trả về nội dung để render.
   * @param mouse - Đối tượng chứa tọa độ x và y của chuột.
   */
  render: (mouse: MousePosition) => React.ReactNode;
}

Giải thích:

  • MousePosition: Một interface đơn giản mô tả dữ liệu vị trí chuột.
  • MouseTrackerProps: Interface cho props của component MouseTracker. Điều quan trọng ở đây là prop render.
    • Nó được định nghĩa là một hàm (mouse: MousePosition) => React.ReactNode.
    • Điều này nói rằng, prop render phải là một hàm nhận một đối tượng kiểu MousePosition làm tham số và trả về một thứ gì đó có thể render được trong React (React.ReactNode, bao gồm JSX, string, number, null, undefined, mảng các thứ render được...).

Tiếp theo, tạo component MouseTracker:

// MouseTracker.tsx
import * as React from 'react';
import { MouseTrackerProps } from './mouse-tracker.types'; // Import kiểu đã định nghĩa

class MouseTracker extends React.Component<MouseTrackerProps, MousePosition> {
  // State để lưu vị trí chuột
  state: MousePosition = { x: 0, y: 0 };

  // Hàm xử lý sự kiện di chuột
  handleMouseMove = (event: React.MouseEvent<HTMLDivElement>) => {
    this.setState({
      x: event.clientX,
      y: event.clientY
    });
  };

  // ComponentDidMount để thêm event listener khi component mount
  componentDidMount() {
    // Gắn event listener vào toàn bộ cửa sổ hoặc một div bao quanh
    // Trong ví dụ này, chúng ta sẽ gắn vào div chính của component
    // Lưu ý: Gắn vào window/document phổ biến hơn cho toàn màn hình,
    // nhưng gắn vào div của component dễ demo hơn trong 1 vùng nhất định.
    // Ta sẽ gắn vào div chính được render.
  }

  // ComponentWillUnmount để xóa event listener khi component unmount
  componentWillUnmount() {
    // Đảm bảo dọn dẹp event listener để tránh rò rỉ bộ nhớ
    // Nếu gắn vào document/window, cần xóa ở đây.
    // Nếu gắn vào div ref, React sẽ tự dọn dẹp handler onMouseMove.
  }

  render() {
    // Lấy state hiện tại (vị trí chuột)
    const mouse = this.state;

    // GỌI prop 'render' và truyền state 'mouse' xuống
    // Component này chỉ render KẾT QUẢ của việc gọi hàm 'render'
    return (
      <div style={{ height: '100vh', border: '1px solid #ccc', padding: '20px' }} onMouseMove={this.handleMouseMove}>
        <h1>Di chuột vào khung này!</h1>
        {/* Đây là nơi "phép thuật" xảy ra */}
        {this.props.render(mouse)}
      </div>
    );
  }
}

export default MouseTracker;

Giải thích:

  • MouseTracker là một class component (render props cũng hoạt động tốt với functional components và Hooks, nhưng class component thể hiện rõ stateful logic hơn trong ví dụ này).
  • Nó có state mouse lưu trữ xy.
  • handleMouseMove cập nhật state khi chuột di chuyển.
  • Trong phương thức render(), thay vì tự xây dựng UI hiển thị xy, nó gọi this.props.render(mouse) và render kết quả trả về. Nó truyền đối tượng mouse hiện tại (state) làm đối số cho hàm render.

Cuối cùng, cách sử dụng MouseTracker từ một component khác:

// App.tsx
import * as React from 'react';
import MouseTracker from './MouseTracker';

function App() {
  return (
    <div>
      <h2> dụ Render Props: Mouse Tracker</h2>

      {/* Sử dụng MouseTracker */}
      <MouseTracker
        // Định nghĩa hàm render - Đây là phần UI mà MouseTracker sẽ render
        render={(mouse) => (
          // Hàm này nhận 'mouse' (kiểu MousePosition nhờ TypeScript)
          // và trả về JSX để hiển thị vị trí chuột.
          <div>
            <p>Vị trí chuột:</p>
            <p>x: {mouse.x}, y: {mouse.y}</p>
            {/* Bạn có thể render bất cứ thứ gì ở đây dựa trên 'mouse' */}
          </div>
        )}
      />

      <hr/>

      {/* Bạn có thể sử dụng MouseTracker lần nữa, render UI khác */}
      <MouseTracker
         render={(pos) => (
             // Ví dụ khác: chỉ hiển thị x hoặc chỉ hiển thị y
             <p>Chỉ theo dõi x: {pos.x}</p>
         )}
      />

    </div>
  );
}

export default App;

Giải thích:

  • Component App sử dụng MouseTracker.
  • Nó truyền một hàm vào prop render.
  • Hàm này nhận một đối số mouse. TypeScript biết rằng mouse sẽ có kiểu MousePosition nhờ định nghĩa trong MouseTrackerProps. Điều này cho phép chúng ta truy cập mouse.xmouse.y một cách an toàn.
  • Hàm này trả về JSX mà chúng ta muốn hiển thị (trong trường hợp này là đoạn văn bản hiển thị tọa độ).
  • Lưu ý bạn có thể sử dụng MouseTracker nhiều lần và cung cấp các hàm render khác nhau cho mỗi lần, chứng tỏ tính linh hoạt.

Điểm mấu chốt: Component MouseTracker chỉ lo việc theo dõi vị trí chuột. Component App (hoặc bất kỳ component nào sử dụng MouseTracker) lo việc hiển thị vị trí chuột đó theo cách nó muốn. Render props giúp chia sẻ logic stateful (x, y và cách cập nhật chúng) mà không ràng buộc về UI.

Ví dụ 2: Pattern "Function as a Child"

Render props cũng có thể được thực hiện bằng cách sử dụng prop children. Khi children là một hàm, nó hoạt động giống hệt như một render prop.

Định nghĩa kiểu:

// data-provider.types.ts
import * as React from 'react';

interface FetchedData {
  id: number;
  name: string;
  // ... các thuộc tính khác của dữ liệu
}

interface DataProviderState {
    isLoading: boolean;
    error: string | null;
    data: FetchedData[] | null;
}

interface DataProviderProps {
    url: string; // Prop để chỉ định URL data
    /**
     * Hàm children nhận trạng thái fetch data và trả về nội dung để render.
     * @param state - Đối tượng chứa isLoading, error, data.
     */
    children: (state: DataProviderState) => React.ReactNode;
}

Component DataProvider:

// DataProvider.tsx
import * as React from 'react';
import { DataProviderProps, DataProviderState } from './data-provider.types';

// Giả lập hàm fetch data
async function fetchData(url: string): Promise<DataProviderState['data']> {
    console.log(`Fetching from ${url}...`);
    return new Promise(resolve => {
        setTimeout(() => {
            resolve([{ id: 1, name: 'Item 1' }, { id: 2, name: 'Item 2' }]);
        }, 1000); // Giả lập độ trễ network
    });
}

class DataProvider extends React.Component<DataProviderProps, DataProviderState> {
    state: DataProviderState = {
        isLoading: false,
        error: null,
        data: null,
    };

    async componentDidMount() {
        this.setState({ isLoading: true, error: null });
        try {
            const data = await fetchData(this.props.url);
            this.setState({ data, isLoading: false });
        } catch (error: any) {
            this.setState({ error: error.message, isLoading: false });
        }
    }

    render() {
        // Component này chỉ render kết quả của việc gọi hàm 'children'
        // và truyền toàn bộ state của nó xuống
        return (
            <>
                {/* Gọi hàm children (prop children là một hàm) */}
                {this.props.children(this.state)}
            </>
        );
    }
}

export default DataProvider;

Giải thích:

  • DataProvider quản lý state isLoading, error, data.
  • Trong componentDidMount, nó giả lập việc fetch dữ liệu.
  • Trong render, nó gọi this.props.children(this.state) và render kết quả. Nó truyền toàn bộ state hiện tại xuống hàm children.

Cách sử dụng DataProvider (Function as a Child):

// App.tsx
import * as React from 'react';
import DataProvider from './DataProvider';

function App() {
  return (
    <div>
      <h2> dụ Function as a Child: Data Provider</h2>

      {/* Sử dụng DataProvider với prop children là một hàm */}
      <DataProvider url="/api/items">
        {/* Hàm này nhận 'state' (kiểu DataProviderState nhờ TS) */}
        {/* và trả về JSX để render dựa trên trạng thái fetch */}
        {(state) => {
          // Nhờ TypeScript, chúng ta biết 'state' có isLoading, error, data
          if (state.isLoading) {
            return <p>Đang tải dữ liệu...</p>;
          }

          if (state.error) {
            return <p style={{ color: 'red' }}>Lỗi: {state.error}</p>;
          }

          if (state.data) {
            return (
              <ul>
                {state.data.map(item => (
                  <li key={item.id}>{item.name}</li>
                ))}
              </ul>
            );
          }

          // Trạng thái ban đầu hoặc không có dữ liệu
          return <p>Chưa  dữ liệu.</p>;
        }}
      </DataProvider>

    </div>
  );
}

export default App;

Giải thích:

  • Component App sử dụng DataProvider và cung cấp một hàm làm nội dung giữa cặp thẻ mở/đóng (<DataProvider>...</DataProvider>).
  • Hàm này nhận một đối số state. TypeScript đảm bảo rằng state sẽ có kiểu DataProviderState, cho phép chúng ta truy cập state.isLoading, state.error, và state.data một cách an toàn và kiểm tra kiểu dữ liệu của chúng.
  • Hàm này trả về JSX tùy thuộc vào trạng thái fetch dữ liệu.

Ví dụ này cho thấy cách Render props (dưới dạng "Function as a Child") giúp tách biệt logic fetch data khỏi UI hiển thị data. DataProvider không quan tâm dữ liệu được hiển thị trong <p>, <ul>, hay <Table>; nó chỉ cung cấp dữ liệu và trạng thái.

Ưu điểm và Nhược điểm của Render Props

Ưu điểm:

  • Linh hoạt cao: Consumer component có toàn quyền kiểm soát việc render UI dựa trên dữ liệu được cung cấp.
  • Tái sử dụng logic hiệu quả: Dễ dàng chia sẻ logic stateful giữa nhiều component.
  • Tránh xung đột tên prop: So với HOCs, render props ít có khả năng gây xung đột tên prop vì dữ liệu được truyền trực tiếp qua đối số hàm.
  • Minh bạch về nguồn gốc dữ liệu: Rõ ràng dữ liệu bạn đang làm việc đến từ đâu (là đối số của hàm render hoặc children).
  • Tích hợp tốt với TypeScript: TypeScript cung cấp sự an toàn kiểu mạnh mẽ cho đối số của hàm render/children.

Nhược điểm:

  • Độ sâu của cây component: Trong React DevTools, việc lồng ghép các component sử dụng render props có thể tạo ra một cây hơi sâu và khó đọc hơn một chút so với Custom Hooks.
  • Hiệu năng (lưu ý): Nếu hàm render được định nghĩa inline trong phương thức render của component cha (component sử dụng render prop), nó sẽ tạo ra một hàm mới trên mỗi lần render. Điều này có thể gây ra vấn đề hiệu năng nếu Provider component sử dụng PureComponent hoặc React.memo và prop render là prop phức tạp duy nhất thay đổi, khiến Consumer component luôn bị re-render ngay cả khi dữ liệu bên trong không đổi (vì prop render là hàm mới). Giải pháp là định nghĩa hàm render bên ngoài hoặc sử dụng useCallback (với Hooks).
  • Verbosity (dài dòng): Cú pháp có thể trông hơi dài dòng hơn so với Custom Hooks, đặc biệt khi chỉ cần truy cập một vài giá trị đơn giản.

Render Props so với Custom Hooks

Với sự ra đời của Hooks, nhiều trường hợp sử dụng trước đây của Render props hoặc HOCs giờ đây có thể được giải quyết bằng Custom Hooks một cách thanh lịchtrực tiếp hơn, đặc biệt là việc chia sẻ logic stateful đơn thuần.

Tuy nhiên, Render props vẫn có chỗ đứng của nó, đặc biệt khi component Provider không chỉ đơn thuần là cung cấp logic, mà còn đóng vai trò như một container quản lý trạng thái và chờ component tiêu thụ định nghĩa cách render bên trong nó. Ví dụ MouseTracker ở trên có thể làm bằng Hook (useMousePosition), nhưng MouseTracker như một component render props lại có cấu trúc khác biệt, đóng vai trò như một vùng theo dõi chuột.

Hooks tập trung vào chia sẻ logic stateful, Render props tập trung vào chia sẻ khả năng điều khiển việc render dựa trên state/logic. Cả hai đều là công cụ mạnh mẽ và bạn nên hiểu rõ khi nào nên sử dụng cái nào. Trong nhiều trường hợp hiện đại, Custom Hooks thường là lựa chọn đầu tiên cho việc tái sử dụng logic.

TypeScript giúp gì trong toàn bộ quá trình?

Như đã thấy qua các ví dụ, TypeScript buộc chúng ta phải khai báo rõ ràng kiểu dữ liệu mà hàm render hoặc children mong đợi nhận được. Khi bạn sử dụng component MouseTracker hay DataProvider, IDE của bạn sẽ gợi ý các thuộc tính có sẵn trên đối tượng được truyền vào hàm render (mouse.x, mouse.y, state.isLoading, state.data, v.v.). Nếu bạn cố gắng truy cập một thuộc tính không tồn tại hoặc sai kiểu, TypeScript sẽ báo lỗi ngay lập tức trong quá trình phát triển, giúp bạn phát hiện lỗi sớm hơn và xây dựng ứng dụng đáng tin cậy hơn.

Việc định nghĩa các interface MouseTrackerProps hay DataProviderProps với chữ ký hàm render hoặc children là bước then chốt để tận dụng tối đa Render props pattern trong môi trường TypeScript.

Comments

There are no comments at the moment.