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àogiá 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ề inputsoutputs 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:

  1. Chúng ta import useStateuseEffect từ React.
  2. Đị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ể cho data khi sử dụng hook.
  3. Khai báo hàm useFetch nhận vào url (kiểu string) và trả về một object có kiểu FetchState<T>.
  4. Sử dụng useState để khởi tạo và quản lý state data, loading, và error. Lưu ý cách chúng ta chỉ định kiểu dữ liệu cho state (<T | null>, <boolean>, <any | null>).
  5. Sử dụng useEffect để thực hiện side effect là fetch dữ liệu.
  6. Bên trong useEffect, chúng ta định nghĩa một hàm fetchData bất đồng bộ (async) để thực hiện request fetch.
  7. Trước khi fetch, set loading thành true và reset error.
  8. 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 state data. Nếu có lỗi, set vào state error.
  9. Khối finally luôn được thực thi sau try hoặc catch, đảm bảo loading luôn được set về false sau khi request hoàn thành.
  10. Mảng dependency [url] trong useEffect quy định rằng hiệu ứng này sẽ chạy lại mỗi khi giá trị của url thay đổi.
  11. 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:

  1. Import useStateuseEffect.
  2. Khai báo hàm useLocalStorage với generic <T>, nhận vào key (string) và initialValue (T). Nó trả về một mảng (giống như useState) chứa giá trị (T) và hàm setter.
  3. Hàm readValue được tạo ra để xử lý logic đọc giá trị từ localStorage. Nó sử dụng try...catch để đảm bảo an toàn khi localStorage không khả dụng hoặc dữ liệu bị lỗi định dạng.
  4. useState được sử dụng để tạo state storedValue. Giá trị khởi tạo là kết quả của hàm readValue, đảm bảo state ban đầu đồng bộ với localStorage.
  5. useEffect được sử dụng để đồng bộ state ngược lại với localStorage. Mỗi khi storedValue thay đổi (hoặc key 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ào localStorage.
  6. 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ặc useLocalStorage trong functional component của mình.
  • Nhờ TypeScript, khi sử dụng useFetch<User>, trình editor sẽ biết rằng biến user sẽ có kiểu dữ liệu User | null, loadingbooleanerrorany | null. Điều này ngăn bạn truy cập sai thuộc tính (ví dụ: user.address nếu User không có thuộc tính address) 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ến themestring và hàm setTheme chỉ nhận các giá trị hoặc hàm updater trả về string.
  • Code trong component trở nên tinh gọndễ đọ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

There are no comments at the moment.