Custom Hook trong React: Tách Logic Dùng Lại Đúng Cách

1. Tổng quan

Khi nhiều component trong ứng dụng React cùng cần một đoạn logic giống nhau — gọi API, đọc localStorage, debounce input — bạn dễ rơi vào tình trạng copy-paste state và useEffect qua lại. Bài này chỉ cho bạn cách gói logic đó vào custom hook: một hàm bắt đầu bằng use, tận dụng các hook có sẵn (useState, useEffect, useRef…) để tái sử dụng. Đọc xong, bạn sẽ viết được useFetch, useLocalStorage, useDebounce và biết cách test chúng bằng renderHook.

2. Vấn đề: logic lặp lại giữa component

Giả sử bạn có hai component đều cần gọi API và quản lý loading/error:

function CustomerList() {
  const [data, setData] = useState(null);
  const [loading, setLoading] = useState(true);
  const [error, setError] = useState(null);

  useEffect(() => {
    fetch('/api/customers')
      .then((res) => res.json())
      .then(setData)
      .catch(setError)
      .finally(() => setLoading(false));
  }, []);

  // ...render
}

function OrderList() {
  const [data, setData] = useState(null);
  const [loading, setLoading] = useState(true);
  const [error, setError] = useState(null);

  useEffect(() => {
    fetch('/api/orders')
      .then((res) => res.json())
      .then(setData)
      .catch(setError)
      .finally(() => setLoading(false));
  }, []);

  // ...render
}

CustomerList và OrderList chứa gần như cùng một đoạn logic, chỉ khác URL. Nếu mai bạn muốn thêm retry hoặc cancel request, phải sửa ở mọi nơi — đây chính là dấu hiệu cần custom hook.

3. Custom hook là gì

Custom hook là một hàm JavaScript thông thường, tên bắt đầu bằng use, bên trong gọi các hook khác của React. Nó không trả về JSX — nó trả về state và hàm, giống như một “mini store” gắn với component gọi nó.

Lưu ý: Mỗi lần một component gọi custom hook, React tạo một instance state riêng. Hai component cùng dùng useFetch không chia sẻ state với nhau — chúng chỉ chia sẻ logic.

4. Viết custom hook đầu tiên: useFetch

// useFetch.js
import { useState, useEffect } from 'react';

export function useFetch(url) {
  const [data, setData] = useState(null);
  const [loading, setLoading] = useState(true);
  const [error, setError] = useState(null);

  useEffect(() => {
    let cancelled = false;
    setLoading(true);
    setError(null);

    fetch(url)
      .then((res) => {
        if (!res.ok) throw new Error(`HTTP ${res.status}`);
        return res.json();
      })
      .then((json) => {
        if (!cancelled) setData(json);
      })
      .catch((err) => {
        if (!cancelled) setError(err);
      })
      .finally(() => {
        if (!cancelled) setLoading(false);
      });

    return () => {
      cancelled = true;
    };
  }, [url]);

  return { data, loading, error };
}

Giải thích:

  • cancelled dùng để tránh race condition: nếu url đổi trước khi request cũ xong, kết quả cũ không được set vào state nữa.
  • Cleanup function (return () => { cancelled = true }) chạy khi component unmount hoặc url thay đổi — đúng vòng đời của useEffect.
  • Hook trả về một object { data, loading, error } để nơi gọi dễ destructure.

Component giờ gọn lại:

function CustomerList() {
  const { data, loading, error } = useFetch('/api/customers');

  if (loading) return <p>Đang tải...</p>;
  if (error) return <p>Lỗi: {error.message}</p>;
  return <ul>{data.map((c) => <li key={c.id}>{c.name}</li>)}</ul>;
}

function OrderList() {
  const { data, loading, error } = useFetch('/api/orders');
  // logic render tương tự, không còn fetch logic lặp lại
}

5. Ví dụ thứ hai: useLocalStorage

Logic đồng bộ state với localStorage cũng rất hay bị copy-paste:

// useLocalStorage.js
import { useState, useEffect } from 'react';

export function useLocalStorage(key, initialValue) {
  const [value, setValue] = useState(() => {
    const stored = window.localStorage.getItem(key);
    return stored !== null ? JSON.parse(stored) : initialValue;
  });

  useEffect(() => {
    window.localStorage.setItem(key, JSON.stringify(value));
  }, [key, value]);

  return [value, setValue];
}

Giải thích:

  • useState(() => ...) dùng lazy initializer — hàm chỉ chạy một lần khi mount, không đọc localStorage lại ở mỗi render.
  • Hook trả về tuple [value, setValue] giống y useState, giúp người dùng quen thuộc ngay.

Dùng trong component:

function ThemeToggle() {
  const [theme, setTheme] = useLocalStorage('theme', 'light');

  return (
    <button onClick={() => setTheme(theme === 'light' ? 'dark' : 'light')}>
      Theme hiện tại: {theme}
    </button>
  );
}

5.1. Khi state phụ thuộc giá trị trước

Nếu bạn cần cập nhật dựa trên giá trị cũ (ví dụ counter), hãy dùng functional update để tránh stale closure:

setValue((prev) => prev + 1); // an toàn hơn setValue(value + 1)

6. Ví dụ thứ ba: useDebounce

Debounce input tìm kiếm là bài toán rất phổ biến:

// useDebounce.js
import { useState, useEffect } from 'react';

export function useDebounce(value, delayMs = 300) {
  const [debounced, setDebounced] = useState(value);

  useEffect(() => {
    const timer = setTimeout(() => setDebounced(value), delayMs);
    return () => clearTimeout(timer);
  }, [value, delayMs]);

  return debounced;
}

Giải thích:

  • Mỗi lần value đổi, một setTimeout mới được tạo; setTimeout cũ bị clearTimeout trong cleanup.
  • Chỉ khi người dùng ngừng gõ trong delayMs thì debounced mới cập nhật.
function SearchBox() {
  const [query, setQuery] = useState('');
  const debouncedQuery = useDebounce(query, 500);

  useEffect(() => {
    if (debouncedQuery) {
      fetch(`/api/search?q=${debouncedQuery}`);
    }
  }, [debouncedQuery]);

  return <input value={query} onChange={(e) => setQuery(e.target.value)} />;
}

7. Quy tắc đặt tên và Rules of Hooks

Custom hook phải tuân theo Rules of Hooks như hook built-in:

  1. Tên bắt đầu bằng use — ESLint plugin eslint-plugin-react-hooks dựa vào tiền tố này để kiểm tra rule, nếu không có use nó sẽ không nhận diện đây là hook.
  2. Chỉ gọi hook ở top-level của hàm, không gọi trong if, for, hay callback.
  3. Chỉ gọi hook trong function component hoặc custom hook khác, không gọi trong hàm thường.
npm install eslint-plugin-react-hooks --save-dev
{
  "plugins": ["react-hooks"],
  "rules": {
    "react-hooks/rules-of-hooks": "error",
    "react-hooks/exhaustive-deps": "warn"
  }
}

Bật exhaustive-deps giúp bạn không quên dependency trong useEffect của custom hook — nguyên nhân phổ biến nhất gây bug “stale data”.

8. Test custom hook

Vì custom hook không trả về JSX, bạn không thể render nó như component. Thư viện @testing-library/react cung cấp renderHook để gọi hook trong môi trường test.

<!-- Không áp dụng cho dự án React; dependency cài qua npm, xem bên dưới -->
npm install --save-dev vitest @testing-library/react
// useDebounce.test.jsx
import { renderHook, act } from '@testing-library/react';
import { describe, it, expect, vi } from 'vitest';
import { useDebounce } from './useDebounce';

describe('useDebounce', () => {
  it('chỉ cập nhật giá trị sau khi hết delay', () => {
    vi.useFakeTimers();
    const { result, rerender } = renderHook(
      ({ value, delay }) => useDebounce(value, delay),
      { initialProps: { value: 'a', delay: 300 } }
    );

    expect(result.current).toBe('a');

    rerender({ value: 'ab', delay: 300 });
    expect(result.current).toBe('a'); // chưa đổi ngay

    act(() => {
      vi.advanceTimersByTime(300);
    });

    expect(result.current).toBe('ab'); // đã debounce xong
    vi.useRealTimers();
  });
});

Giải thích:

  • renderHook nhận một hàm gọi hook, trả về result.current — giá trị hook trả về ở lần render hiện tại.
  • rerender mô phỏng component re-render với props mới (giống value đổi khi người dùng gõ).
  • vi.useFakeTimers() + act(() => vi.advanceTimersByTime(...)) cho phép kiểm soát thời gian mà không cần chờ thật, test chạy nhanh và ổn định.
✓ useDebounce > chỉ cập nhật giá trị sau khi hết delay

9. Lỗi hay gặp và khi nào không nên dùng

Lỗi hay gặp:

  • Quên cleanup trong useEffect: với useFetch, nếu không có cờ cancelled, request cũ trả về sau có thể ghi đè data mới — gây hiển thị sai dữ liệu.
  • Thiếu dependency: nếu useEffect trong hook dùng biến bên ngoài nhưng không khai báo trong dependency array, hook sẽ dùng giá trị “cũ” (stale closure).
  • Trả về object mới mỗi render không cần thiết: nếu hook trả về { data, loading, error } bằng object literal mỗi lần, component dùng React.memo phía dưới có thể re-render dư thừa vì reference đổi. Có thể dùng useMemo nếu đo được vấn đề thật.

Khi nào KHÔNG nên viết custom hook:

  • Logic chỉ dùng ở một chỗ duy nhất và không có khả năng tái sử dụng — tách ra chỉ làm code khó theo dõi hơn (phải nhảy qua file khác để hiểu).
  • Logic hoàn toàn là tính toán thuần (pure function), không liên quan state/effect — nên viết hàm JS thông thường, không cần hook.
  • Khi bạn cần chia sẻ state (không chỉ logic) giữa nhiều component ở các vị trí khác nhau trong tree — custom hook không làm được điều này, bạn cần Context hoặc state management library (Zustand, Redux…).

Lưu ý: Custom hook giải quyết vấn đề trùng lặp logic, không giải quyết vấn đề chia sẻ state giữa component. Đừng nhầm hai bài toán này.

10. Kết luận

Custom hook là công cụ tách logic stateful ra khỏi component, giúp code gọn, dễ test, dễ tái sử dụng. Bạn chỉ cần nhớ: đặt tên bắt đầu bằng use, tuân thủ Rules of Hooks, và test bằng renderHook thay vì render. Với useFetch, useLocalStorage, useDebounce ở trên, bạn đã có bộ khung để viết thêm các hook khác như useMediaQuery, useForm theo đúng tinh thần “viết một lần, dùng nhiều nơi”.

reactcustom-hookjavascriptfrontendtesting