Bài 18.2: useContext hook với TypeScript

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:
- Tạo Context: Sử dụng
React.createContext()
. - 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. - 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ặcundefined
). - 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ặcnull
. 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 khiProvider
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ủaThemeProvider
, bao gồmchildren
kiểuReactNode
.useState<'light' | 'dark'>('light')
: Khai báo statetheme
với kiểu union literal'light' | 'dark'
.const contextValue: ThemeContextType = { theme, toggleTheme };
: Chúng ta tạo đối tượngcontextValue
và é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ấpcontextValue
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ện và an 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ợpnull
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ểuThemeContextType | null
.if (!context) { ... }
: Đây là bước quan trọng đảm bảo an toàn. Nếucontext
lànull
(nghĩa làuseTheme
được gọi bên ngoàiThemeProvider
), chúng ta ném ra một lỗi mô tả rõ ràng vấn đề.return context;
: Nếucontext
không phải lànull
, TypeScript biết rằng nó phải làThemeContextType
(nhờ logic kiểm traif
), 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 importThemeProvider
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ọiuseTheme()
. Nhờ custom hook và TypeScript, chúng ta biết rằng giá trị trả về chắc chắn là kiểuThemeContextType
và có sẵn thuộc tínhtheme
vàtoggleTheme
để 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ó trongThemeContextType
, 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:
- 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ụ.
- 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". - 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ả.
- 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.
- 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