Bài 16.3: Custom hooks trong TypeScript

Bài 16.3: Custom hooks trong TypeScript
Chào mừng trở lại với chuỗi bài viết về Lập trình Web Front-end! Hôm nay, chúng ta sẽ đi sâu vào một khái niệm cực kỳ mạnh mẽ trong React: Custom Hooks. Và khi kết hợp Custom Hooks với sự an toàn kiểu dữ liệu của TypeScript, chúng ta sẽ mở khóa tiềm năng tái sử dụng logic vượt trội, giúp code của bạn sạch sẽ, dễ hiểu và ít lỗi hơn đáng kể.
Nếu bạn đã làm việc với React một thời gian, chắc hẳn bạn đã quen thuộc với các built-in hooks như useState
, useEffect
, useContext
,... Chúng giúp chúng ta quản lý state, side effects và truy cập context một cách gọn gàng trong các functional component. Tuy nhiên, sẽ có lúc bạn nhận ra mình đang lặp đi lặp lại cùng một đoạn logic xử lý state hoặc side effect ở nhiều component khác nhau. Đây chính là lúc Custom Hooks tỏa sáng!
Custom Hooks Là Gì?
Đơn giản mà nói, Custom Hook chỉ là một hàm JavaScript (hoặc TypeScript) thông thường có tên bắt đầu bằng từ khóa use
. Hàm này có thể gọi các React hooks khác (như useState
, useEffect
, useContext
,...) để đóng gói và tái sử dụng logic xử lý stateful giữa các component mà không cần chia sẻ UI.
Điểm cốt lõi là: Custom Hooks không chia sẻ state. Chúng chia sẻ logic để quản lý state. Mỗi lần bạn sử dụng một custom hook trong một component, nó sẽ có state riêng của mình.
Tại Sao Phải Dùng Custom Hooks?
Việc sử dụng Custom Hooks mang lại rất nhiều lợi ích:
- Tái sử dụng Logic: Đây là lý do chính! Thay vì viết lại cùng một đoạn code quản lý data fetching, form input hay animation logic, bạn đóng gói nó vào một custom hook và sử dụng ở bất kỳ đâu cần đến. Điều này giúp giảm đáng kể lượng code lặp lại (DRY - Don't Repeat Yourself).
- Trừu tượng hóa (Abstraction): Custom Hooks giúp ẩn đi những chi tiết triển khai phức tạp, chỉ để lộ một interface đơn giản cho component sử dụng. Component của bạn trở nên gọn gàng hơn, chỉ tập trung vào việc hiển thị UI dựa trên dữ liệu và trạng thái nhận được từ hook.
- Nâng cao khả năng Đọc hiểu và Bảo trì: Khi logic được tách riêng vào các hooks có tên rõ ràng (
useFetch
,useFormInput
,useWindowSize
), việc đọc hiểu component trở nên dễ dàng hơn. Khi cần thay đổi logic đó, bạn chỉ cần chỉnh sửa ở một nơi duy nhất là custom hook, thay vì phải sửa ở nhiều component khác nhau. - Dễ dàng kiểm thử (Testing): Logic trong custom hooks thường độc lập với UI, giúp việc viết unit test cho chúng trở nên đơn giản hơn rất nhiều.
Vai trò của TypeScript khi dùng Custom Hooks
Khi kết hợp Custom Hooks với TypeScript, bạn nhận được thêm một lớp bảo vệ và minh bạch mạnh mẽ:
- An toàn kiểu dữ liệu: TypeScript giúp định nghĩa rõ ràng kiểu dữ liệu cho các tham số đầu vào và giá trị trả về của custom hook. Điều này loại bỏ nguy cơ truyền sai kiểu dữ liệu hoặc xử lý sai dữ liệu nhận được, giúp bắt lỗi ngay trong quá trình phát triển thay vì lúc chạy (runtime).
- IntelliSense và Autocomplete: Trình editor của bạn (VS Code,...) sẽ hiểu rõ cấu trúc dữ liệu mà hook mong đợi hoặc trả về, cung cấp gợi ý code thông minh, giúp bạn sử dụng hook dễ dàng và chính xác hơn.
- Refactoring dễ dàng hơn: Khi bạn thay đổi cấu trúc dữ liệu hoặc interface của hook, TypeScript sẽ báo lỗi ở tất cả những nơi sử dụng hook đó, giúp bạn tự tin hơn khi thực hiện các thay đổi lớn.
Tạo Custom Hook Đầu Tiên: useFetch
với TypeScript
Hãy bắt đầu với một ví dụ kinh điển: fetch dữ liệu từ API. Logic này thường xuất hiện ở nhiều component.
Đầu tiên, hãy nghĩ về inputs và outputs của hook này.
- Input: URL của API cần fetch (
string
). Có thể thêm options cho fetch. - Output: Dữ liệu nhận được (
any
hoặc một kiểu dữ liệu cụ thể), trạng thái loading (boolean
), và lỗi nếu có (any
).
Chúng ta sẽ cần sử dụng useState
để lưu trữ dữ liệu, trạng thái loading và lỗi, cùng với useEffect
để thực hiện việc fetch khi URL thay đổi.
import { useState, useEffect } from 'react';
// Định nghĩa kiểu dữ liệu cho giá trị trả về của hook
interface FetchState<T> {
data: T | null;
loading: boolean;
error: any | null;
}
// Sử dụng Generic Type <T> để hook có thể fetch bất kỳ loại dữ liệu nào
function useFetch<T>(url: string): FetchState<T> {
const [data, setData] = useState<T | null>(null);
const [loading, setLoading] = useState<boolean>(true);
const [error, setError] = useState<any | null>(null);
useEffect(() => {
const fetchData = async () => {
setLoading(true);
setError(null); // Reset error on new fetch
try {
const response = await fetch(url);
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
const result = await response.json();
setData(result);
} catch (err) {
setError(err);
setData(null); // Clear data on error
} finally {
setLoading(false);
}
};
// Chỉ fetch nếu url tồn tại
if (url) {
fetchData();
}
// Cleanup function (optional but good practice for some effects)
// return () => { abort controller logic if needed };
}, [url]); // Dependency array: effect chạy lại khi url thay đổi
return { data, loading, error };
}
export default useFetch;
Giải thích code:
- Chúng ta import
useState
vàuseEffect
từ React. - Định nghĩa một interface
FetchState<T>
để mô tả kiểu dữ liệu trả về của hook.<T>
là một generic type parameter, cho phép chúng ta chỉ định kiểu dữ liệu cụ thể chodata
khi sử dụng hook. - Khai báo hàm
useFetch
nhận vàourl
(kiểustring
) và trả về một object có kiểuFetchState<T>
. - Sử dụng
useState
để khởi tạo và quản lý statedata
,loading
, vàerror
. Lưu ý cách chúng ta chỉ định kiểu dữ liệu cho state (<T | null>
,<boolean>
,<any | null>
). - Sử dụng
useEffect
để thực hiện side effect là fetch dữ liệu. - Bên trong
useEffect
, chúng ta định nghĩa một hàmfetchData
bất đồng bộ (async
) để thực hiện requestfetch
. - Trước khi fetch, set
loading
thànhtrue
và reseterror
. - Sử dụng khối
try...catch
để xử lý kết quả fetch và bắt lỗi. Nếu fetch thành công, parse JSON và set vào statedata
. Nếu có lỗi, set vào stateerror
. - Khối
finally
luôn được thực thi sautry
hoặccatch
, đảm bảoloading
luôn được set vềfalse
sau khi request hoàn thành. - Mảng dependency
[url]
tronguseEffect
quy định rằng hiệu ứng này sẽ chạy lại mỗi khi giá trị củaurl
thay đổi. - Hook trả về một object chứa
data
,loading
, vàerror
.
Nhờ TypeScript, khi sử dụng useFetch
, bạn sẽ biết chính xác hook này nhận vào gì và trả về gì, và kiểu dữ liệu của data
sẽ được inference hoặc chỉ định rõ ràng.
Tạo Custom Hook Khác: useLocalStorage
Một ví dụ phổ biến khác là đồng bộ hóa state với localStorage
.
- Input: Khóa (
string
) và giá trị khởi tạo (T
). - Output: Giá trị hiện tại trong state (
T
) và một hàm setter để cập nhật giá trị ((value: T | ((val: T) => T)) => void
).
import { useState, useEffect } from 'react';
// Sử dụng Generic Type <T> cho giá trị lưu trữ
function useLocalStorage<T>(key: string, initialValue: T): [T, (value: T | ((val: T) => T)) => void] {
// Hàm để đọc giá trị từ localStorage
const readValue = (): T => {
try {
const item = window.localStorage.getItem(key);
// Nếu item tồn tại, parse JSON và trả về
return item ? JSON.parse(item) : initialValue;
} catch (error) {
// Log lỗi và trả về giá trị mặc định nếu có vấn đề khi đọc
console.warn(`Error reading localStorage key “${key}”:`, error);
return initialValue;
}
};
// Sử dụng useState để quản lý giá trị
// Khởi tạo state bằng giá trị đọc từ localStorage (hoặc giá trị mặc định)
const [storedValue, setStoredValue] = useState<T>(readValue);
// Sử dụng useEffect để cập nhật localStorage mỗi khi storedValue thay đổi
useEffect(() => {
try {
// Lưu giá trị mới vào localStorage
window.localStorage.setItem(key, JSON.stringify(storedValue));
} catch (error) {
// Log lỗi nếu có vấn đề khi ghi vào localStorage
console.warn(`Error setting localStorage key “${key}”:`, error);
}
}, [key, storedValue]); // Dependency array: effect chạy khi key hoặc storedValue thay đổi
// Trả về giá trị hiện tại và hàm setter
return [storedValue, setStoredValue];
}
export default useLocalStorage;
Giải thích code:
- Import
useState
vàuseEffect
. - Khai báo hàm
useLocalStorage
với generic<T>
, nhận vàokey
(string
) vàinitialValue
(T
). Nó trả về một mảng (giống nhưuseState
) chứa giá trị (T
) và hàm setter. - Hàm
readValue
được tạo ra để xử lý logic đọc giá trị từlocalStorage
. Nó sử dụngtry...catch
để đảm bảo an toàn khilocalStorage
không khả dụng hoặc dữ liệu bị lỗi định dạng. useState
được sử dụng để tạo statestoredValue
. Giá trị khởi tạo là kết quả của hàmreadValue
, đảm bảo state ban đầu đồng bộ vớilocalStorage
.useEffect
được sử dụng để đồng bộ state ngược lại vớilocalStorage
. Mỗi khistoredValue
thay đổi (hoặckey
thay đổi, mặc dùkey
thường cố định), hiệu ứng này sẽ chạy, ghi giá trị mới (đãJSON.stringify
) vàolocalStorage
.- Hook trả về mảng
[storedValue, setStoredValue]
, cho phép component sử dụng hook dễ dàng truy cập giá trị và cập nhật nó.
TypeScript giúp đảm bảo rằng initialValue
có kiểu T
, storedValue
luôn là kiểu T
, và hàm setter nhận vào giá trị kiểu T
hoặc một hàm updater trả về kiểu T
.
Sử dụng Custom Hooks trong Component
Sau khi đã tạo các custom hook với TypeScript, việc sử dụng chúng trong component trở nên rất trực quan và an toàn nhờ vào kiểu dữ liệu rõ ràng.
Ví dụ sử dụng useFetch
:
import React from 'react';
import useFetch from './hooks/useFetch'; // Giả định bạn lưu hook trong thư mục hooks
// Định nghĩa kiểu dữ liệu cho dữ liệu bạn mong đợi từ API
interface User {
id: number;
name: string;
email: string;
}
function UserProfile({ userId }: { userId: number }) {
// Sử dụng useFetch, chỉ định kiểu dữ liệu mong muốn là User
const { data: user, loading, error } = useFetch<User>(
`https://jsonplaceholder.typicode.com/users/${userId}`
);
if (loading) {
return <p>Đang tải thông tin người dùng...</p>;
}
if (error) {
return <p>Lỗi: {error.message}</p>;
}
if (!user) {
return null; // Hoặc hiển thị trạng thái không tìm thấy
}
// TypeScript biết 'user' bây giờ là kiểu User
return (
<div>
<h2>{user.name}</h2>
<p>Email: {user.email}</p>
{/* Render thông tin khác của user */}
</div>
);
}
export default UserProfile;
Ví dụ sử dụng useLocalStorage
:
import React from 'react';
import useLocalStorage from './hooks/useLocalStorage'; // Giả định bạn lưu hook trong thư mục hooks
function ThemeSwitcher() {
// Sử dụng useLocalStorage, chỉ định kiểu dữ liệu là string
// Giá trị mặc định là 'light'
const [theme, setTheme] = useLocalStorage<string>('app-theme', 'light');
const toggleTheme = () => {
setTheme(prevTheme => (prevTheme === 'light' ? 'dark' : 'light'));
};
// Ứng dụng theme vào body hoặc root element (ví dụ)
useEffect(() => {
document.body.className = theme;
}, [theme]);
return (
<div>
<p>Theme hiện tại: **{theme}**</p>
<button onClick={toggleTheme}>Chuyển đổi Theme</button>
</div>
);
}
export default ThemeSwitcher;
Giải thích các ví dụ sử dụng:
- Việc sử dụng custom hook giống hệt như sử dụng các built-in hooks. Bạn chỉ cần gọi hàm
useFetch
hoặcuseLocalStorage
trong functional component của mình. - Nhờ TypeScript, khi sử dụng
useFetch<User>
, trình editor sẽ biết rằng biếnuser
sẽ có kiểu dữ liệuUser | null
,loading
làboolean
vàerror
làany | null
. Điều này ngăn bạn truy cập sai thuộc tính (ví dụ:user.address
nếuUser
không có thuộc tínhaddress
) và cung cấp gợi ý code chính xác. - Tương tự, khi dùng
useLocalStorage<string>
, TypeScript đảm bảo rằng biếntheme
làstring
và hàmsetTheme
chỉ nhận các giá trị hoặc hàm updater trả vềstring
. - Code trong component trở nên tinh gọn và dễ đọc hơn rất nhiều vì logic quản lý trạng thái phức tạp đã được đóng gói bên trong custom hook. Component chỉ việc sử dụng trạng thái và gọi các hàm được cung cấp bởi hook.
Cấu trúc dự án cho Custom Hooks
Thông thường, bạn nên tạo một thư mục riêng để chứa các custom hooks của mình, ví dụ src/hooks
. Bạn có thể tạo từng file riêng cho mỗi hook (useFetch.ts
, useLocalStorage.ts
) và có thể export chúng ra từ một file index (src/hooks/index.ts
) để dễ dàng import tập trung:
// src/hooks/index.ts
export { default as useFetch } from './useFetch';
export { default as useLocalStorage } from './useLocalStorage';
// Export các hooks khác tại đây
Sau đó, bạn có thể import chúng như thế này:
import { useFetch, useLocalStorage } from './hooks'; // Import từ file index
Điều này giúp tổ chức code của bạn một cách ngăn nắp và dễ quản lý.
Comments