Bài 18.1: Typing Context trong React

Chào mừng trở lại với chuỗi bài viết về lập trình Front-end! Trong bài học này, chúng ta sẽ đi sâu vào một khía cạnh quan trọng khi sử dụng React Context API trong các dự án sử dụng TypeScript: đó là cách định kiểu (Typing) Context.

React Context API là một công cụ mạnh mẽ giúp chúng ta truyền dữ liệu "xuyên cây" các component mà không cần phải "đẩy props" (prop drilling) qua nhiều tầng. Tuy nhiên, khi kết hợp với TypeScript, việc đảm bảo an toàn kiểu cho dữ liệu trong Context đòi hỏi chúng ta phải hiểu rõ cách định nghĩa và sử dụng các kiểu dữ liệu phù hợp. Điều này không chỉ giúp ngăn ngừa lỗi runtime mà còn cải thiện đáng kể trải nghiệm lập trình của bạn.

Tại Sao Typing Context Lại Quan Trọng?

Sử dụng TypeScript với React mang lại rất nhiều lợi ích, và việc áp dụng TypeScript cho Context cũng không ngoại lệ:

  1. An toàn kiểu dữ liệu: TypeScript sẽ bắt các lỗi về kiểu ngay tại thời điểm biên dịch (compile time), trước khi ứng dụng của bạn chạy. Điều này giúp bạn phát hiện sớm các vấn đề như truyền sai loại dữ liệu, quên xử lý các trường hợp undefined hoặc null tiềm ẩn, hoặc cố gắng truy cập các thuộc tính không tồn tại trên đối tượng Context.
  2. Cải thiện trải nghiệm lập trình (DX): Với kiểu dữ liệu rõ ràng, IDE của bạn có thể cung cấp các tính năng hữu ích như tự động hoàn thành code, kiểm tra lỗi trực tiếp khi gõ, và gợi ý tham số/thuộc tính. Điều này giúp bạn code nhanh hơn, chính xác hơn và giảm thiểu việc phải liên tục tra cứu cấu trúc dữ liệu của Context.
  3. Mã nguồn dễ đọc và bảo trì: Việc định nghĩa rõ ràng kiểu cho Context Value giúp bất kỳ ai đọc code cũng hiểu ngay Context này chứa những dữ liệu gì và có những phương thức nào. Khi cần thay đổi cấu trúc của Context, TypeScript sẽ giúp bạn xác định tất cả những nơi bị ảnh hưởng, làm cho quá trình refactoring trở nên an toàn và hiệu quả hơn.

Nói cách khác, việc typing Context giúp biến Context từ một "chiếc hộp đen" chứa dữ liệu không rõ ràng thành một "thành phần có giao diện" được định nghĩa rõ ràng, dễ hiểu và dễ làm việc cùng.

Bắt Tay Vào Thực Hiện: Typing Một Context Đơn Giản

Hãy bắt đầu với một ví dụ đơn giản: một Context để quản lý trạng thái theme (sáng/tối).

Bước 1: Định nghĩa Kiểu cho Context Value

Trước tiên, chúng ta cần định nghĩa rõ ràng cấu trúc dữ liệu mà Context này sẽ cung cấp. Context theme của chúng ta sẽ cần biết theme hiện tại (theme: 'light' | 'dark') và một hàm để thay đổi theme (setTheme).

// types.ts hoặc trong cùng file Context
interface ThemeContextType {
  theme: 'light' | 'dark';
  setTheme: (theme: 'light' | 'dark') => void;
}
  • Giải thích code:
    • Chúng ta sử dụng interface (hoặc type) để mô tả cấu trúc của Context Value.
    • theme là một chuỗi chỉ có thể nhận giá trị 'light' hoặc 'dark'.
    • setTheme là một hàm nhận vào một tham số kiểu 'light' | 'dark' và không trả về gì (void).

Bước 2: Tạo Context với Kiểu Đã Định Nghĩa

Bây giờ, chúng ta tạo Context bằng React.createContext. Đây là điểm cần lưu ý với TypeScript: hàm createContext yêu cầu một giá trị mặc định (default value). Giá trị này cần phải phù hợp với kiểu dữ liệu bạn đã định nghĩa. Tuy nhiên, trong hầu hết các trường hợp thực tế, Context chỉ có giá trị "đúng" khi được sử dụng bên trong một Provider. Do đó, giá trị mặc định thường là undefined hoặc null.

Để thể hiện điều này trong kiểu dữ liệu, chúng ta sử dụng union type: ThemeContextType | undefined.

// ThemeContext.ts
import React from 'react';
import { ThemeContextType } from './types'; // Import kiểu đã tạo

// Tạo Context với giá trị mặc định là undefined
const ThemeContext = React.createContext<ThemeContextType | undefined>(undefined);

export default ThemeContext;
  • Giải thích code:
    • <ThemeContextType | undefined>: Đây là kiểu dữ liệu mà Context này sẽ chứa. Nó có thể là một đối tượng ThemeContextType hoặc undefined.
    • undefined): Đây là giá trị mặc định được truyền vào createContext. Khi một component gọi useContext(ThemeContext) bên ngoài Provider, nó sẽ nhận được giá trị này (undefined). Điều này quan trọng vì chúng ta cần xử lý trường hợp này khi tiêu thụ Context.

Bước 3: Cung cấp Context bằng Provider

Component Provider sẽ "bao bọc" các component con cần truy cập Context và cung cấp giá trị thực tế của Context. Giá trị này phải khớp với phần không phải undefined trong kiểu union mà chúng ta đã định nghĩa (ThemeContextType).

// ThemeProvider.tsx
import React, { useState } from 'react';
import ThemeContext from './ThemeContext'; // Import Context đã tạo
import { ThemeContextType } from './types'; // Import kiểu

const ThemeProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => {
  const [theme, setTheme] = useState<'light' | 'dark'>('light');

  // Tạo đối tượng giá trị Context, đảm bảo nó khớp với ThemeContextType
  const contextValue: ThemeContextType = {
    theme,
    setTheme,
  };

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

export default ThemeProvider;
  • Giải thích code:
    • Chúng ta sử dụng useState để quản lý trạng thái theme.
    • Đối tượng { theme, setTheme } được tạo ra và khai báo kiểuThemeContextType. TypeScript sẽ kiểm tra xem đối tượng này có thực sự khớp với ThemeContextType hay không. Nếu không, bạn sẽ nhận được lỗi biên dịch ngay đây.
    • Đối tượng contextValue này sau đó được truyền vào prop value của ThemeContext.Provider.

Bước 4: Tiêu thụ Context bằng useContext

Khi sử dụng hook useContext để lấy giá trị từ Context, kiểu dữ liệu trả về sẽ là kiểu union mà chúng ta đã định nghĩa khi tạo Context, tức là ThemeContextType | undefined. Chúng ta bắt buộc phải xử lý trường hợp nó có thể là undefined.

Cách phổ biến và an toàn nhất là kiểm tra xem giá trị trả về có phải là undefined không. Nếu có, điều đó có nghĩa là hook useContext đã được gọi ở đâu đó bên ngoài ThemeProvider, và đó thường là một lỗi trong cấu trúc ứng dụng.

// ThemeToggler.tsx (Một component sử dụng theme)
import React from 'react';
import ThemeContext from './ThemeContext'; // Import Context

const ThemeToggler: React.FC = () => {
  const context = React.useContext(ThemeContext);

  // **Quan trọng:** Kiểm tra xem context có tồn tại không
  if (context === undefined) {
    throw new Error('ThemeToggler must be used within a ThemeProvider');
    // Hoặc xử lý một cách khác, tùy thuộc vào logic ứng dụng
  }

  const { theme, setTheme } = context; // Bây giờ context đã chắc chắn là ThemeContextType

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

  return (
    <button onClick={toggleTheme}>
      Chuyển sang Theme: {theme === 'light' ? 'Dark' : 'Light'}
    </button>
  );
};

export default ThemeToggler;
  • Giải thích code:
    • const context = React.useContext(ThemeContext);: Lệnh này trả về giá trị của Context, có kiểu là ThemeContextType | undefined.
    • if (context === undefined): Đây là bước kiểm tra an toàn. Nếu contextundefined, chúng ta biết rằng ThemeToggler đang được sử dụng không đúng cách (bên ngoài ThemeProvider), và chúng ta nên thông báo lỗi rõ ràng.
    • const { theme, setTheme } = context;: Chỉ sau khi kiểm tra context không phải undefined, TypeScript mới "biết" rằng context bây giờ chắc chắn có kiểu là ThemeContextType, cho phép chúng ta truy cập themesetTheme một cách an toàn mà không cần ép kiểu (type assertion).
Mẫu Thiết Kế Kinh Điển: Tạo Custom Hook

Việc kiểm tra if (context === undefined) mỗi lần tiêu thụ Context có thể trở nên lặp lại. Một mẫu thiết kế rất phổ biến và được khuyến khích là tạo một custom hook để đóng gói logic tiêu thụ Context và kiểm tra undefined.

// useTheme.ts (Hoặc gom chung vào file ThemeContext.ts)
import React from 'react';
import ThemeContext from './ThemeContext'; // Import Context đã tạo
import { ThemeContextType } from './types'; // Import kiểu

const useTheme = (): ThemeContextType => {
  const context = React.useContext(ThemeContext);

  // Kiểm tra undefined tại đây
  if (context === undefined) {
    throw new Error('useTheme must be used within a ThemeProvider');
  }

  // Trả về context đã được đảm bảo là ThemeContextType
  return context;
};

export default useTheme;
  • Giải thích code:
    • Chúng ta tạo một hàm useTheme là một custom hook (đặt tên bắt đầu bằng use).
    • Hook này gọi useContext và thực hiện việc kiểm tra undefined một lần.
    • Kiểu trả về của hook được khai báo rõ ràng là ThemeContextType. Điều này đảm bảo rằng bất kỳ component nào sử dụng useTheme sẽ nhận được một đối tượng chắc chắn có kiểu ThemeContextType.

Bây giờ, component ThemeToggler trở nên gọn gàng hơn nhiều:

// ThemeToggler.tsx (Sử dụng custom hook)
import React from 'react';
import useTheme from './useTheme'; // Import custom hook

const ThemeToggler: React.FC = () => {
  const { theme, setTheme } = useTheme(); // Dữ liệu đã được đảm bảo an toàn kiểu

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

  return (
    <button onClick={toggleTheme}>
      Chuyển sang Theme: {theme === 'light' ? 'Dark' : 'Light'}
    </button>
  );
};

export default ThemeToggler;
  • Giải thích code: Việc sử dụng useTheme() trực tiếp trả về { theme, setTheme } với kiểu dữ liệu chính xác nhờ vào custom hook. Logic kiểm tra undefined đã được trừu tượng hóa bên trong hook.
Ví Dụ Nâng Cao Hơn: Context với State và Dispatch (Hook useReducer)

Context thường được sử dụng để quản lý state phức tạp hơn, kết hợp với hook useReducer. Typing cho trường hợp này cũng tương tự, nhưng chúng ta cần định nghĩa kiểu cho cả state và các action có thể xảy ra.

Giả sử chúng ta có một Context quản lý state của một giỏ hàng đơn giản.

// cartTypes.ts
export interface CartItem {
  id: string;
  name: string;
  price: number;
  quantity: number;
}

export interface CartState {
  items: CartItem[];
  total: number;
}

// Định nghĩa các kiểu action khác nhau
export type CartAction =
  | { type: 'ADD_ITEM'; payload: { item: CartItem } }
  | { type: 'REMOVE_ITEM'; payload: { itemId: string } }
  | { type: 'UPDATE_QUANTITY'; payload: { itemId: string; quantity: number } }
  | { type: 'CLEAR_CART' };

// Định nghĩa kiểu cho Context Value
export interface CartContextType {
  state: CartState;
  dispatch: React.Dispatch<CartAction>; // Kiểu của hàm dispatch từ useReducer
}
  • Giải thích code:
    • Chúng ta định nghĩa kiểu cho CartItemCartState.
    • CartAction là một discriminated union type. Mỗi đối tượng action có một trường type riêng biệt ('ADD_ITEM', 'REMOVE_ITEM',...) và một trường payload chứa dữ liệu cần thiết cho action đó. TypeScript sử dụng trường type để phân biệt các loại action và suy luận kiểu dữ liệu trong payload một cách chính xác khi bạn xử lý chúng trong reducer.
    • CartContextType chứa state (có kiểu CartState) và dispatch. Kiểu của hàm dispatch được lấy từ TypeScript của React: React.Dispatch<CartAction>, đảm bảo rằng hàm dispatch chỉ chấp nhận các đối tượng có kiểu là CartAction.

Tiếp theo, tạo reducer và Context:

// cartReducer.ts
import { CartState, CartAction, CartItem } from './cartTypes';

// Khởi tạo state ban đầu
export const initialCartState: CartState = {
  items: [],
  total: 0,
};

// Hàm helper để tính lại tổng tiền
const calculateTotal = (items: CartItem[]): number => {
  return items.reduce((sum, item) => sum + item.price * item.quantity, 0);
};

// Reducer function
export const cartReducer = (state: CartState, action: CartAction): CartState => {
  switch (action.type) {
    case 'ADD_ITEM': {
      // Logic thêm sản phẩm
      const existingItem = state.items.find(item => item.id === action.payload.item.id);
      let newItems;
      if (existingItem) {
          newItems = state.items.map(item =>
              item.id === action.payload.item.id
                  ? { ...item, quantity: item.quantity + action.payload.item.quantity }
                  : item
          );
      } else {
          newItems = [...state.items, action.payload.item];
      }
      return {
          ...state,
          items: newItems,
          total: calculateTotal(newItems),
      };
    }
    case 'REMOVE_ITEM': {
      // Logic xóa sản phẩm
      const newItems = state.items.filter(item => item.id !== action.payload.itemId);
      return {
          ...state,
          items: newItems,
          total: calculateTotal(newItems),
      };
    }
    case 'UPDATE_QUANTITY': {
       // Logic cập nhật số lượng
       const newItems = state.items.map(item =>
           item.id === action.payload.itemId
               ? { ...item, quantity: action.payload.quantity }
               : item
       ).filter(item => item.quantity > 0); // Xóa nếu số lượng về 0
       return {
           ...state,
           items: newItems,
           total: calculateTotal(newItems),
       };
    }
    case 'CLEAR_CART':
      return initialCartState;
    default:
      // Đảm bảo TypeScript kiểm tra tất cả các trường hợp action
      const _exhaustiveCheck: never = action;
      throw new Error(`Unhandled action type: ${_exhaustiveCheck}`);
  }
};
  • Giải thích code:
    • Hàm cartReducer nhận vào state (kiểu CartState) và action (kiểu CartAction), và trả về state mới (kiểu CartState). TypeScript đảm bảo bạn xử lý đúng kiểu cho state và action.
    • Bên trong switch, khi bạn kiểm tra action.type, TypeScript sẽ tự động "thu hẹp" (narrow) kiểu của action xuống kiểu cụ thể hơn trong union. Ví dụ, trong case 'ADD_ITEM', TypeScript biết action chắc chắn có thuộc tính payload với kiểu { item: CartItem }.
    • Dòng const _exhaustiveCheck: never = action; ở cuối default case là một trick của TypeScript để đảm bảo bạn đã xử lý tất cả các trường hợp trong CartAction. Nếu bạn thêm một loại action mới vào CartAction union mà quên xử lý trong switch, TypeScript sẽ báo lỗi tại dòng này.

Bây giờ tạo Context và Provider:

// CartContext.ts
import React, { useReducer, ReactNode } from 'react';
import { CartState, CartAction, CartContextType, initialCartState, cartReducer } from './cartTypes'; // Import tất cả

// Tạo Context
const CartContext = React.createContext<CartContextType | undefined>(undefined);

// Tạo Provider component
const CartProvider: React.FC<{ children: ReactNode }> = ({ children }) => {
  const [state, dispatch] = useReducer(cartReducer, initialCartState);

  // Giá trị Context cung cấp state và dispatch
  const contextValue: CartContextType = { state, dispatch };

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

// Tạo custom hook để tiêu thụ Context
const useCart = (): CartContextType => {
  const context = React.useContext(CartContext);
  if (context === undefined) {
    throw new Error('useCart must be used within a CartProvider');
  }
  return context;
};

export { CartProvider, useCart }; // Export Provider và hook
  • Giải thích code:
    • Tương tự ví dụ theme, chúng ta tạo Context với kiểu CartContextType | undefined.
    • Trong CartProvider, chúng ta sử dụng useReducer với reducer và initial state đã định nghĩa.
    • Giá trị truyền vào Provider là đối tượng { state, dispatch }, và chúng ta khai báo kiểu rõ ràng là CartContextType để TypeScript kiểm tra.
    • Custom hook useCart đóng gói logic useContext và kiểm tra undefined, trả về một đối tượng chắc chắn có kiểu CartContextType.

Bây giờ, bất kỳ component nào cần truy cập giỏ hàng hoặc các action của nó chỉ cần gọi useCart():

// SomeCartComponent.tsx
import React from 'react';
import { useCart } from './CartContext'; // Import custom hook

const SomeCartComponent: React.FC = () => {
  const { state, dispatch } = useCart(); // An toàn kiểu!

  const addItemToCart = (item: { id: string; name: string; price: number }) => {
    // TypeScript biết dispatch chỉ nhận CartAction
    dispatch({ type: 'ADD_ITEM', payload: { item: { ...item, quantity: 1 } } });
  };

  const removeItem = (itemId: string) => {
      dispatch({ type: 'REMOVE_ITEM', payload: { itemId } });
  }

  return (
    <div>
      <h2>Giỏ hàng của bạn</h2>
      <ul>
        {state.items.map(item => (
          <li key={item.id}>
            {item.name} ({item.quantity}) - {item.price * item.quantity} VND
            <button onClick={() => removeItem(item.id)}>Xóa</button>
          </li>
        ))}
      </ul>
      <p>Tổng cộng: {state.total} VND</p>

      <button onClick={() => addItemToCart({ id: 'p1', name: 'Sản phẩm A', price: 100 })}>
        Thêm Sản phẩm A
      </button>
    </div>
  );
};

export default SomeCartComponent;
  • Giải thích code:
    • Component này chỉ cần gọi useCart() và nhận về statedispatch với kiểu dữ liệu đã được định nghĩa rõ ràng.
    • Khi gọi dispatch, TypeScript sẽ kiểm tra xem đối tượng action bạn truyền vào có khớp với một trong các kiểu con trong CartAction union hay không. Điều này giúp ngăn ngừa việc gửi các action không hợp lệ đến reducer.
    • Bạn có thể truy cập state.items hoặc state.total một cách an toàn vì kiểu của state đã được định nghĩa. Khi lặp qua state.items, mỗi item được TypeScript biết là có kiểu CartItem.
Một Vài Lưu Ý và Mẹo Hữu Ích
  • Đừng quên default value trong createContext: Mặc dù thường là undefined, nhưng việc cung cấp giá trị mặc định là bắt buộc trong React. Việc này giúp React hoạt động đúng đắn trong một số trường hợp (ví dụ: rendering bên ngoài Provider). Tuy nhiên, đừng nhầm lẫn nó với giá trị ban đầu của state trong Provider.
  • Xử lý undefinedbắt buộc: Khi sử dụng useContext(MyContext) với Context được tạo bằng createContext<MyContextType | undefined>(undefined), kết quả trả về luôn có thể là undefined. Việc kiểm tra if (context === undefined) hoặc sử dụng custom hook là cách đúng đắn để đảm bảo an toàn. Ép kiểu (useContext(MyContext) as MyContextType) có thể tiện lợi, nhưng nó bỏ qua bước kiểm tra an toàn và tiềm ẩn nguy cơ lỗi runtime nếu bạn quên bọc component trong Provider. Custom hook là lựa chọn tốt nhất.
  • Đặt tên nhất quán: Sử dụng các quy ước đặt tên như MyContext, MyProvider, useMyContext, MyContextType giúp mã nguồn dễ hiểu hơn.
  • Chia nhỏ Context: Nếu Context của bạn trở nên quá lớn và chứa nhiều loại dữ liệu không liên quan, hãy cân nhắc tách nó thành nhiều Context nhỏ hơn. Điều này không chỉ giúp quản lý code dễ hơn mà còn có lợi cho hiệu năng của React (các component chỉ render lại khi Context mà chúng dùng thay đổi). Việc typing từng Context nhỏ cũng sẽ dễ dàng hơn.

Comments

There are no comments at the moment.