Bài 18.2: useContext hook với TypeScript

Chào mừng trở lại với series Lập trình Web Front-end của FullhouseDev! Hôm nay, chúng ta sẽ đi sâu vào một công cụ cực kỳ mạnh mẽ trong React để giải quyết bài toán đau đầu: truyền dữ liệu giữa các component ở xa nhau mà không cần "khoan tầng" (prop drilling). Đó chính là useContext hook. Và khi kết hợp nó với sức mạnh của TypeScript, chúng ta không chỉ giải quyết vấn đề truyền dữ liệu mà còn đảm bảo tính an toàn và rõ ràng về kiểu dữ liệu trong ứng dụng của mình.

"Prop Drilling" - Cơn ác mộng truyền thống

Trong React, cách phổ biến nhất để truyền dữ liệu từ component cha xuống component con là thông qua props. Điều này hoạt động tốt với các component gần nhau. Tuy nhiên, khi bạn cần truyền một dữ liệu hoặc hàm nào đó từ một component rất cao trong cây component xuống một component rất sâu bên dưới, bạn sẽ phải truyền nó qua tất cả các component trung gian. Hiện tượng này gọi là Prop Drilling (khoan tầng prop).

Ví dụ: Bạn có dữ liệu người dùng ở component App, và một component UserAvatar nằm sâu trong cây component cần hiển thị tên người dùng. Bạn sẽ phải truyền userData từ App -> Layout -> Sidebar -> UserProfile -> UserAvatar.

Prop Drilling khiến code trở nên:

  • Khó đọc: Khó theo dõi dữ liệu đến từ đâu.
  • Khó bảo trì: Việc thay đổi tên prop hoặc cấu trúc dữ liệu ở component nguồn buộc bạn phải cập nhật tất cả các component trung gian.
  • Tăng khả năng lỗi: Dễ mắc lỗi khi truyền hoặc nhận props qua nhiều tầng.

useContext được tạo ra để giải cứu chúng ta khỏi tình huống này.

useContext Hook - Giải pháp cho Prop Drilling

React Context cung cấp một cách để truyền dữ liệu xuyên suốt cây component mà không cần truyền props thủ công ở mọi cấp độ. Về cơ bản, bạn tạo ra một "context" (ngữ cảnh) chứa dữ liệu, sau đó bọc phần cây component cần truy cập dữ liệu đó bằng một Provider của context. Bất kỳ component nào bên trong Provider đều có thể sử dụng useContext hook để "đọc" dữ liệu từ context đó.

Hãy tưởng tượng Context như một cái "kênh phát sóng". Bạn phát dữ liệu lên kênh, và bất kỳ ai "bắt sóng" kênh đó đều nhận được dữ liệu mà không cần bạn gửi trực tiếp cho từng người.

Về cơ bản:

  1. Tạo Context: Sử dụng React.createContext().
  2. Cung cấp (Provide) Context: Sử dụng <MyContext.Provider value={/* dữ liệu */}> để bọc các component cần truy cập dữ liệu.
  3. Tiêu thụ (Consume) Context: Sử dụng useContext(MyContext) trong component con để lấy giá trị từ context.

Tại sao cần TypeScript với useContext?

Khi bạn làm việc với useContext trong JavaScript thuần, React không biết kiểu dữ liệu của giá trị trong context là gì. Điều này có thể dẫn đến:

  • Lỗi Runtime: Bạn có thể cố gắng truy cập một thuộc tính trên một đối tượng không tồn tại hoặc có kiểu dữ liệu khác bạn mong đợi (ví dụ: truy cập .name trên một giá trị là null hoặc undefined).
  • Khó khăn cho Developer: Không có gợi ý (autocompletion) về cấu trúc dữ liệu của context, khiến bạn phải nhớ hoặc tra cứu.
  • Refactoring rủi ro: Thay đổi cấu trúc context mà không có kiểm tra kiểu dữ liệu có thể phá vỡ nhiều nơi sử dụng.

TypeScript giải quyết những vấn đề này bằng cách cho phép bạn định nghĩa rõ ràng kiểu dữ liệu mà context sẽ chứa. Điều này mang lại:

  • An toàn kiểu dữ liệu: TypeScript kiểm tra tại thời điểm biên dịch, bắt lỗi sai kiểu dữ liệu trước khi code chạy.
  • Nâng cao trải nghiệm lập trình: Gợi ý code chính xác, dễ dàng nhìn thấy cấu trúc dữ liệu.
  • Refactoring tự tin hơn: Trình biên dịch TypeScript sẽ báo cho bạn biết những chỗ cần cập nhật khi cấu trúc context thay đổi.

Triển khai useContext với TypeScript - Từ A đến Z

Hãy cùng xây dựng một ví dụ đơn giản: Quản lý trạng thái theme (sáng/tối) của ứng dụng bằng useContext và TypeScript.

Bước 1: Định nghĩa kiểu dữ liệu cho Context

Điều đầu tiên cần làm là mô tả dữ liệu mà context của chúng ta sẽ chứa. Chúng ta sẽ sử dụng một interface hoặc type của TypeScript.

// src/contexts/ThemeContext.tsx (hoặc .ts)

// Định nghĩa kiểu dữ liệu mà context sẽ chứa
interface ThemeContextType {
  theme: 'light' | 'dark';
  toggleTheme: () => void; // Context cũng có thể chứa hàm
}

Giải thích: ThemeContextType mô tả một đối tượng có hai thuộc tính: theme (chỉ chấp nhận giá trị 'light' hoặc 'dark') và toggleTheme (là một hàm không nhận tham số và không trả về giá trị).

Bước 2: Tạo Context và xử lý giá trị khởi tạo

Chúng ta sẽ sử dụng React.createContext() để tạo context. Đây là nơi chúng ta cần cẩn thận với TypeScript, đặc biệt là giá trị khởi tạo (initial value).

createContext nhận một tham số là giá trị mặc định. Giá trị này sẽ được sử dụng nếu một component cố gắng đọc context ngoài phạm vi của một Provider. Tuy nhiên, thường thì giá trị thực tế chỉ tồn tại bên trong Provider.

Một cách phổ biến và an toàn để xử lý điều này với TypeScript là khai báo kiểu của context có thể là kiểu dữ liệu của bạn hoặc null. Giá trị mặc định ban đầu sẽ là null. Sau đó, khi tiêu thụ context, chúng ta sẽ kiểm tra xem nó có phải là null hay không.

// src/contexts/ThemeContext.tsx

import React, { createContext, useContext, useState, ReactNode } from 'react';

interface ThemeContextType {
  theme: 'light' | 'dark';
  toggleTheme: () => void;
}

// 1. Tạo Context với kiểu dữ liệu là ThemeContextType hoặc null
const ThemeContext = createContext<ThemeContextType | null>(null);

// ... các phần khác sẽ viết tiếp ở dưới

Giải thích:

  • createContext<ThemeContextType | null>(null): Chúng ta nói với TypeScript rằng context này sẽ chứa một giá trị có kiểu là ThemeContextType hoặc null. Giá trị khởi tạo ban đầu là null. Điều này an toàn vì nó phản ánh đúng trạng thái ban đầu trước khi Provider cung cấp giá trị thực.
Bước 3: Tạo Provider Component

Để cung cấp giá trị thực cho context, chúng ta tạo một component Provider riêng. Component này sẽ quản lý state (ở đây là theme) và cung cấp state đó cùng với các hàm liên quan (toggleTheme) thông qua value prop của ThemeContext.Provider.

// src/contexts/ThemeContext.tsx (tiếp tục)

// ... (interfaces và createContext ở trên)

interface ThemeProviderProps {
  children: ReactNode; // Prop 'children' để bọc các component con
}

// 2. Tạo Provider Component
export const ThemeProvider: React.FC<ThemeProviderProps> = ({ children }) => {
  const [theme, setTheme] = useState<'light' | 'dark'>('light'); // State thực tế

  const toggleTheme = () => {
    setTheme(prevTheme => prevTheme === 'light' ? 'dark' : 'light');
  };

  // Giá trị thực tế sẽ cung cấp qua context
  const contextValue: ThemeContextType = { theme, toggleTheme };

  return (
    <ThemeContext.Provider value={contextValue}>
      {children}
    </ThemeContext.Provider>
  );
};

// ... (hook useContext sẽ viết ở dưới)

Giải thích:

  • ThemeProviderProps: Định nghĩa kiểu cho props của ThemeProvider, bao gồm children kiểu ReactNode.
  • useState<'light' | 'dark'>('light'): Khai báo state theme với kiểu union literal 'light' | 'dark'.
  • const contextValue: ThemeContextType = { theme, toggleTheme };: Chúng ta tạo đối tượng contextValueép kiểu rõ ràng (hoặc để TypeScript tự suy luận, nhưng khai báo rõ ràng giúp code dễ đọc hơn) nó phải là ThemeContextType. Nếu bạn gán sai thuộc tính hoặc sai kiểu, TypeScript sẽ báo lỗi ngay đây.
  • <ThemeContext.Provider value={contextValue}>: Component này bọc các component con (children) và cung cấp contextValue cho toàn bộ cây component bên dưới nó.
Bước 4: Tiêu thụ Context bằng useContext Hook

Bây giờ, bất kỳ component nào nằm bên trong ThemeProvider đều có thể sử dụng useContext(ThemeContext) để lấy giá trị context.

Tuy nhiên, vì chúng ta đã khai báo ThemeContext có kiểu ThemeContextType | null, kết quả của useContext(ThemeContext) sẽ là ThemeContextType | null. Chúng ta phải xử lý trường hợp nó là null. Cách tốt nhất là kiểm tra và ném ra lỗi nếu hook được sử dụng ngoài phạm vi của Provider.

Để làm cho việc tiêu thụ trở nên thuận tiệnan toàn hơn, chúng ta nên tạo một custom hook riêng để gói gọn logic kiểm tra null.

// src/contexts/ThemeContext.tsx (tiếp tục)

// ... (interfaces, createContext, ThemeProvider ở trên)

// 3. Tạo custom hook để tiêu thụ Context
export const useTheme = (): ThemeContextType => {
  const context = useContext(ThemeContext); // Kết quả có thể là ThemeContextType | null

  // Kiểm tra xem hook có được sử dụng bên trong Provider hay không
  if (!context) {
    // Ném lỗi rõ ràng nếu không tìm thấy Provider
    throw new Error('useTheme must be used within a ThemeProvider');
  }

  // Nếu không phải null, trả về giá trị context với kiểu ThemeContextType
  return context;
};

// Bây giờ, bạn chỉ cần export ThemeProvider và useTheme
// export { ThemeProvider, useTheme }; // Hoặc export trực tiếp như đã làm ở trên

Giải thích:

  • export const useTheme = (): ThemeContextType => { ... }: Chúng ta định nghĩa một custom hook tên là useTheme. Kiểu trả về của hook này là ThemeContextType (không có | null) vì chúng ta đã xử lý trường hợp null bên trong hook.
  • const context = useContext(ThemeContext);: Lấy giá trị từ context. Tại thời điểm này, context có kiểu ThemeContextType | null.
  • if (!context) { ... }: Đây là bước quan trọng đảm bảo an toàn. Nếu contextnull (nghĩa là useTheme được gọi bên ngoài ThemeProvider), chúng ta ném ra một lỗi mô tả rõ ràng vấn đề.
  • return context;: Nếu context không phải là null, TypeScript biết rằng nó phải là ThemeContextType (nhờ logic kiểm tra if), nên chúng ta có thể trả về nó an toàn.
Bước 5: Sử dụng Provider và Custom Hook trong ứng dụng

Cuối cùng, chúng ta chỉ cần bọc phần đỉnh của cây component cần truy cập theme bằng ThemeProvider và sử dụng custom hook useTheme ở bất kỳ đâu cần đến giá trị context.

// src/App.tsx

import React from 'react';
import { ThemeProvider } from './contexts/ThemeContext';
import ThemedComponent from './components/ThemedComponent'; // Component ví dụ cần theme

function App() {
  return (
    // Bọc toàn bộ hoặc một phần ứng dụng bằng ThemeProvider
    <ThemeProvider>
      <h1>ng dụng với Theme</h1>
      <ThemedComponent />
      {/* Các component khác cũng có thể dùng useTheme */}
    </ThemeProvider>
  );
}

export default App;

// src/components/ThemedComponent.tsx

import React from 'react';
import { useTheme } from '../contexts/ThemeContext'; // Import custom hook

const ThemedComponent: React.FC = () => {
  // Sử dụng custom hook để lấy theme và toggleTheme
  // Nhờ TypeScript và custom hook, chúng ta biết chắc context không phải null và có kiểu ThemeContextType
  const { theme, toggleTheme } = useTheme();

  const style = {
    padding: '20px',
    backgroundColor: theme === 'light' ? '#f0f0f0' : '#333',
    color: theme === 'light' ? '#333' : '#f0f0f0',
    border: '1px solid #ccc',
    marginTop: '10px',
  };

  return (
    <div style={style}>
      <p>Current Theme: **{theme.toUpperCase()}**</p>
      <button onClick={toggleTheme}>Toggle Theme</button>
    </div>
  );
};

export default ThemedComponent;

Giải thích:

  • Trong App.tsx, chúng ta import ThemeProvider và bọc <ThemedComponent /> (và bất kỳ component nào khác cần theme) bên trong nó.
  • Trong ThemedComponent.tsx, chúng ta chỉ cần gọi useTheme(). Nhờ custom hook và TypeScript, chúng ta biết rằng giá trị trả về chắc chắn là kiểu ThemeContextType và có sẵn thuộc tính themetoggleTheme để sử dụng an toàn. Nếu bạn cố gắng truy cập một thuộc tính không có trong ThemeContextType, TypeScript sẽ báo lỗi ngay lập tức.

Lợi ích của việc sử dụng useContext với TypeScript

Qua ví dụ trên, bạn có thể thấy rõ những lợi ích khi kết hợp useContext với TypeScript:

  1. An toàn Kiểu Dữ liệu Tuyệt đối: Bạn định nghĩa chính xác cấu trúc dữ liệu trong context. TypeScript đảm bảo rằng bạn chỉ có thể cung cấp giá trị phù hợp và chỉ có thể truy cập các thuộc tính/phương thức đã được định nghĩa khi tiêu thụ.
  2. Giảm thiểu Lỗi Runtime: Việc kiểm tra null bắt buộc hoặc được đóng gói trong custom hook giúp ngăn chặn các lỗi "Cannot read properties of null" hoặc "undefined".
  3. Tự động hoàn thành và Gợi ý: IDE của bạn sẽ cung cấp gợi ý chính xác về cấu trúc của context khi bạn sử dụng hook, giúp tăng tốc độ code và giảm lỗi chính tả.
  4. Code Rõ ràng và Dễ Đọc: Việc định nghĩa kiểu dữ liệu và sử dụng custom hook giúp code sử dụng context trở nên tường minh hơn.
  5. Refactoring Dễ Dàng và An Toàn: Nếu bạn thay đổi cấu trúc của ThemeContextType, TypeScript sẽ chỉ ra tất cả những nơi cần cập nhật trong code của bạn (bao gồm cả nơi cung cấp và nơi tiêu thụ context), giúp quá trình refactoring an toàn và hiệu quả hơn rất nhiều.

Khi nào nên sử dụng useContext?

useContext là một lựa chọn tuyệt vời cho:

  • Truyền các giá trị "toàn cục" không thay đổi thường xuyên, ví dụ: thông tin xác thực người dùng, cài đặt theme, cấu hình ngôn ngữ.
  • Chia sẻ các hàm callback đơn giản giữa các component ở xa nhau.
  • Thay thế prop drilling ở mức độ vừa phải.

Tuy nhiên, đối với các trường hợp quản lý trạng thái phức tạp, có nhiều hành động (actions), hoặc cần quản lý side effects quy mô lớn, các thư viện quản lý state chuyên biệt như Redux, Zustand, Recoil có thể phù hợp hơn. Context không tối ưu cho các giá trị thay đổi rất thường xuyên mà nhiều component khác nhau cùng lắng nghe, vì khi giá trị context thay đổi, tất cả các component sử dụng useContext đó sẽ re-render theo mặc định.

Tóm lại

useContext hook là một công cụ mạnh mẽ trong React giúp đơn giản hóa việc truyền dữ liệu qua cây component, giải quyết hiệu quả vấn đề prop drilling. Khi kết hợp với TypeScript, chúng ta nâng cấp khả năng này lên một tầm cao mới với sự an toàn, rõ ràng và trải nghiệm phát triển vượt trội. Bằng cách định nghĩa kiểu dữ liệu cho context, tạo Provider và sử dụng custom hook để tiêu thụ, bạn có thể xây dựng các ứng dụng React với state được quản lý hiệu quả và đáng tin cậy hơn.

Hy vọng bài viết này đã giúp bạn hiểu rõ cách sử dụng useContext hook với TypeScript và những lợi ích mà nó mang lại.

Comments

There are no comments at the moment.