مقالات › React

آموزش ساخت هوک سفارشی در React با الگوها و مثال‌های واقعی

یاد بگیرید منطق تکراری را به هوک‌های سفارشی React منتقل کنید — useFetch، useDebounce، useLocalStorage، useMediaQuery و useOnClickOutside — به‌همراه قوانین طراحی و نکات تست.

هوک‌های سفارشی ابزار اصلی React برای استفاده‌ی مجدد از منطق Stateدار هستند. هر وقت دو کامپوننت به ترکیب یکسانی از State، Effect و Ref نیاز دارند — دریافت داده، همگام‌سازی با Local Storage یا ردیابی اندازه‌ی پنجره — می‌توانید آن را به تابعی منتقل کنید که نامش با use شروع می‌شود. این راهنما نشان می‌دهد چطور هوک سفارشی خوب طراحی کنید و یک جعبه‌ابزار از مثال‌های آماده‌ی Production در اختیارتان می‌گذارد.

هوک سفارشی چیست؟

هوک سفارشی فقط یک تابع جاوااسکریپت است که:

  1. نامش با use شروع می‌شود، و
  2. هوک‌های دیگر را صدا می‌زند.
function useToggle(initial = false) {
  const [value, setValue] = useState(initial);
  const toggle = useCallback(() => setValue((v) => !v), []);
  return [value, toggle];
}

function Faq() {
  const [open, toggleOpen] = useToggle();
  return <button onClick={toggleOpen}>{open ? "Hide" : "Show"} answer</button>;
}

نکته‌ی مهم: هوک سفارشی منطق را به اشتراک می‌گذارد، نه State را. هر کامپوننتی که useToggle را صدا بزند، value مستقل خودش را دارد.

طراحی یک API خوب برای هوک

  • بر اساس کاری که انجام می‌دهد نام‌گذاری کنید: useDebounce، useOnlineStatus، نه useHelper.
  • چیزی را برگردانید که فراخواننده لازم دارد: برای ۱ یا ۲ مقدار یک Tuple ([value, setValue]) و برای بیشتر یک شیء ({ data, error, isLoading }).
  • گزینه (Options) بپذیرید تا انعطاف داشته باشد، با مقادیر پیش‌فرض منطقی.
  • توابع برگشتی را پایدار نگه دارید (useCallback) تا فراخواننده بتواند از آن‌ها در وابستگی Effect استفاده کند.
  • هر چیزی را که هوک راه‌اندازی می‌کند پاک‌سازی کنید.

۱. useDebounce

یک مقدار پرتغییر را با تأخیر اعمال کنید؛ ایده‌آل برای فیلد جستجو.

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

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

  return debounced;
}

function Search() {
  const [query, setQuery] = useState("");
  const debouncedQuery = useDebounce(query, 400);

  useEffect(() => {
    if (debouncedQuery) searchArticles(debouncedQuery);
  }, [debouncedQuery]);

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

۲. useLocalStorage

Stateی که با رفرش صفحه از بین نمی‌رود:

function useLocalStorage(key, initialValue) {
  const [value, setValue] = useState(() => {
    try {
      const stored = localStorage.getItem(key);
      return stored !== null ? JSON.parse(stored) : initialValue;
    } catch {
      return initialValue; // حالت خصوصی، JSON خراب و...
    }
  });

  useEffect(() => {
    try {
      localStorage.setItem(key, JSON.stringify(value));
    } catch {
      /* حافظه پر است یا Storage غیرفعال است */
    }
  }, [key, value]);

  return [value, setValue];
}

const [theme, setTheme] = useLocalStorage("theme", "dark");

به try/catch دفاعی دقت کنید: Storage در حالت خصوصی مرورگر یا وقتی پر شده خطا می‌دهد.

۳. useFetch

یک هوک کوچک دریافت داده با وضعیت بارگذاری، خطا و لغو:

function useFetch(url) {
  const [state, setState] = useState({ data: null, error: null, isLoading: true });

  useEffect(() => {
    if (!url) return;
    const controller = new AbortController();
    setState((s) => ({ ...s, isLoading: true, error: null }));

    fetch(url, { signal: controller.signal })
      .then((res) => {
        if (!res.ok) throw new Error(`HTTP ${res.status}`);
        return res.json();
      })
      .then((data) => setState({ data, error: null, isLoading: false }))
      .catch((error) => {
        if (error.name !== "AbortError") setState({ data: null, error, isLoading: false });
      });

    return () => controller.abort();
  }, [url]);

  return state;
}

const { data: articles, isLoading, error } = useFetch("/api/articles");

برای پروژه‌های واقعی TanStack Query را ترجیح دهید که کش، حذف درخواست‌های تکراری و تلاش مجدد را اضافه می‌کند؛ اما نوشتن این هوک ایده‌های اصلی را یادتان می‌دهد.

۴. useMediaQuery

واکنش به Media Queryهای CSS در جاوااسکریپت:

function useMediaQuery(query) {
  const [matches, setMatches] = useState(() => window.matchMedia(query).matches);

  useEffect(() => {
    const mql = window.matchMedia(query);
    const onChange = (e) => setMatches(e.matches);
    mql.addEventListener("change", onChange);
    setMatches(mql.matches);
    return () => mql.removeEventListener("change", onChange);
  }, [query]);

  return matches;
}

const isMobile = useMediaQuery("(max-width: 640px)");
const prefersReducedMotion = useMediaQuery("(prefers-reduced-motion: reduce)");

۵. useOnClickOutside

بستن منوهای کشویی و مودال‌ها وقتی کاربر جای دیگری کلیک می‌کند:

function useOnClickOutside(ref, handler) {
  useEffect(() => {
    const listener = (event) => {
      if (!ref.current || ref.current.contains(event.target)) return;
      handler(event);
    };
    document.addEventListener("pointerdown", listener);
    return () => document.removeEventListener("pointerdown", listener);
  }, [ref, handler]);
}

function Dropdown() {
  const ref = useRef(null);
  const [open, setOpen] = useState(false);
  useOnClickOutside(ref, () => setOpen(false));
  return <div ref={ref}>{/* … */}</div>;
}

اگر handler یک تابع Inline باشد، در هر رندر عوض می‌شود و Listener دوباره ثبت می‌شود. آن را در useCallback بپیچید یا آخرین Handler را در یک Ref نگه دارید:

function useLatest(value) {
  const ref = useRef(value);
  useLayoutEffect(() => {
    ref.current = value;
  });
  return ref;
}

۶. usePrevious

function usePrevious(value) {
  const ref = useRef();
  useEffect(() => {
    ref.current = value;
  }, [value]);
  return ref.current;
}

const prevCount = usePrevious(count); // مقدار رندر قبلی

۷. ترکیب هوک‌ها

هوک‌های سفارشی می‌توانند از هوک‌های سفارشی دیگر استفاده کنند؛ قدرت واقعی‌شان همین‌جاست:

function useArticleSearch() {
  const [query, setQuery] = useLocalStorage("lastSearch", "");
  const debounced = useDebounce(query, 400);
  const url = debounced ? `/api/articles?q=${encodeURIComponent(debounced)}` : null;
  const { data, isLoading, error } = useFetch(url);
  return { query, setQuery, results: data ?? [], isLoading, error };
}

حالا کامپوننت مثل توصیف یک رفتار خوانده می‌شود و جزئیات در هوک‌های کوچک، متمرکز و تست‌پذیر قرار دارند.

تست هوک‌های سفارشی

از renderHook در React Testing Library استفاده کنید:

import { renderHook, act } from "@testing-library/react";

test("useToggle flips the value", () => {
  const { result } = renderHook(() => useToggle(false));
  act(() => result.current[1]());
  expect(result.current[0]).toBe(true);
});

برای هوک‌های زمانی مثل useDebounce از تایمرهای جعلی (vi.useFakeTimers() یا jest.useFakeTimers()) استفاده کنید.

کِی هوک سفارشی ننویسیم

  • وقتی منطق هیچ State یا Effectی ندارد — یک تابع کمکی معمولی ساده‌تر است.
  • وقتی فقط در یک جا استفاده می‌شود و پیچیده نیست — صبر کنید تا تکرار پیش بیاید.
  • وقتی فقط یک useState را برای تغییر نام می‌پیچید.

جمع‌بندی

هوک‌های سفارشی اجازه می‌دهند منطق Stateدار را با یک API تمیز جدا و دوباره استفاده کنید. نام روشن بگذارید، مقادیر پایدار برگردانید، گزینه بپذیرید، همیشه پاک‌سازی کنید و هوک‌های کوچک را به هوک‌های بزرگ‌تر ترکیب کنید. یک مجموعه‌ی خوب از هوک‌ها — useDebounce، useLocalStorage، useMediaQuery، useOnClickOutside — کامپوننت‌ها را کوتاه‌تر و نگهداری کل پروژه را آسان‌تر می‌کند.

پرسش‌های متداول

آیا دو کامپوننت که از یک هوک استفاده می‌کنند State مشترک دارند؟

نه. هر فراخوانی State مستقل می‌سازد. برای اشتراک State آن را به والد ببرید یا از Context یا یک کتابخانه‌ی State استفاده کنید.

آیا هوک سفارشی می‌تواند JSX برگرداند؟

می‌تواند، اما معمولاً نشانه‌ی این است که باید یک کامپوننت باشد. هوک‌ها داده و تابع برمی‌گردانند.

آیا حتماً باید نام هوک با use شروع شود؟

بله. این پیشوند به Linter ری‌اکت می‌گوید قوانین هوک‌ها را اعمال کند و جلوی باگ‌های پنهان را بگیرد.