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
undefined
hoặcnull
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. - 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. 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
).
- 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ượngThemeContextType
hoặ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ớiThemeContextType
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 propvalue
củ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ếucontext
là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 tracontext
không phảiundefined
, TypeScript mới "biết" rằngcontext
bây giờ chắc chắn có kiểu làThemeContextType
, cho phép chúng ta truy cậptheme
vàsetTheme
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ằnguse
). - Hook này gọi
useContext
và thực hiện việc kiểm traundefined
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ụnguseTheme
sẽ 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
CartItem
vàCartState
. CartAction
là một discriminated union type. Mỗi đối tượng action có một trườngtype
riêng biệt ('ADD_ITEM', 'REMOVE_ITEM',...) và một trườngpayload
chứ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 trongpayload
một cách chính xác khi bạn xử lý chúng trong reducer.CartContextType
chứ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àmdispatch
chỉ 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
cartReducer
nhậ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ủaaction
xuống kiểu cụ thể hơn trong union. Ví dụ, trong case'ADD_ITEM'
, TypeScript biếtaction
chắc chắn có thuộc tínhpayload
với kiểu{ item: CartItem }
. - Dòng
const _exhaustiveCheck: never = action;
ở cuốidefault
case 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àoCartAction
union 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ụnguseReducer
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 logicuseContext
và 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ềstate
vàdispatch
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 trongCartAction
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ặcstate.total
mộ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ý
undefined
là 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
,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