Bài 18.1: Typing Context trong React
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ệ:
- 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
undefinedhoặcnulltiề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. - 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.
- 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ặctype) để mô tả cấu trúc của Context Value. themelà một chuỗi chỉ có thể nhận giá trị 'light' hoặc 'dark'.setThemelà một hàm nhận vào một tham số kiểu'light' | 'dark'và không trả về gì (void).
- Chúng ta sử dụng
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ượngThemeContextTypehoặcundefined.undefined): Đây là giá trị mặc định được truyền vàocreateContext. Khi một component gọiuseContext(ThemeContext)bên ngoàiProvider, 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áitheme. - Đối tượng
{ theme, setTheme }được tạo ra và khai báo kiểu làThemeContextType. TypeScript sẽ kiểm tra xem đối tượng này có thực sự khớp vớiThemeContextTypehay không. Nếu không, bạn sẽ nhận được lỗi biên dịch ngay đây. - Đối tượng
contextValuenày sau đó được truyền vào propvaluecủaThemeContext.Provider.
- Chúng ta sử dụng
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ếucontextlàundefined, chúng ta biết rằngThemeTogglerđang được sử dụng không đúng cách (bên ngoàiThemeProvider), và chúng ta nên thông báo lỗi rõ ràng.const { theme, setTheme } = context;: Chỉ sau khi kiểm tracontextkhông phảiundefined, TypeScript mới "biết" rằngcontextbây giờ chắc chắn có kiểu làThemeContextType, cho phép chúng ta truy cậpthemevàsetThememộ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
useThemelà một custom hook (đặt tên bắt đầu bằnguse). - Hook này gọi
useContextvà thực hiện việc kiểm traundefinedmộ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ụnguseThemesẽ nhận được một đối tượng chắc chắn có kiểuThemeContextType.
- Chúng ta tạo một hàm
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 traundefinedđã đượ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
CartItemvàCartState. CartActionlà một discriminated union type. Mỗi đối tượng action có một trườngtyperiêng biệt ('ADD_ITEM', 'REMOVE_ITEM',...) và một trườngpayloadchứa dữ liệu cần thiết cho action đó. TypeScript sử dụng trườngtypeđể phân biệt các loại action và suy luận kiểu dữ liệu trongpayloadmột cách chính xác khi bạn xử lý chúng trong reducer.CartContextTypechứastate(có kiểuCartState) vàdispatch. Kiểu của hàmdispatchđược lấy từ TypeScript của React:React.Dispatch<CartAction>, đảm bảo rằng hàmdispatchchỉ chấp nhận các đối tượng có kiểu làCartAction.
- Chúng ta định nghĩa kiểu cho
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
cartReducernhận vàostate(kiểuCartState) vàaction(kiểuCartAction), và trả về state mới (kiểuCartState). TypeScript đảm bảo bạn xử lý đúng kiểu cho state và action. - Bên trong
switch, khi bạn kiểm traaction.type, TypeScript sẽ tự động "thu hẹp" (narrow) kiểu củaactionxuống kiểu cụ thể hơn trong union. Ví dụ, trong case'ADD_ITEM', TypeScript biếtactionchắc chắn có thuộc tínhpayloadvới kiểu{ item: CartItem }. - Dòng
const _exhaustiveCheck: never = action;ở cuốidefaultcase là một trick của TypeScript để đảm bảo bạn đã xử lý tất cả các trường hợp trongCartAction. Nếu bạn thêm một loại action mới vàoCartActionunion mà quên xử lý trongswitch, TypeScript sẽ báo lỗi tại dòng này.
- Hàm
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ụnguseReducervới reducer và initial state đã định nghĩa. - Giá trị truyền vào
Providerlà đố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 logicuseContextvà kiểm traundefined, trả về một đối tượng chắc chắn có kiểuCartContextType.
- Tương tự ví dụ theme, chúng ta tạo Context với kiểu
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ềstatevàdispatchvớ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 trongCartActionunion 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.itemshoặcstate.totalmột cách an toàn vì kiểu củastateđã được định nghĩa. Khi lặp quastate.items, mỗiitemđược TypeScript biết là có kiểuCartItem.
- Component này chỉ cần gọi
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ý
undefinedlà bắt buộc: Khi sử dụnguseContext(MyContext)với Context được tạo bằngcreateContext<MyContextType | undefined>(undefined), kết quả trả về luôn có thể làundefined. Việc kiểm traif (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,MyContextTypegiú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