לדלג לתוכן

7.2 הוקים מותאמים אישית הרצאה

הוקים מותאמים אישית - Custom Hooks

הוקים מותאמים אישית הם אחד הכלים החזקים ביותר בריאקט. הם מאפשרים לנו לחלץ לוגיקה משותפת מקומפוננטות ולשתף אותה בין קומפוננטות שונות, בצורה נקייה וקריאה.


למה הוקים מותאמים אישית?

הבעיה - שכפול לוגיקה

נניח שיש לנו שתי קומפוננטות שצריכות לטעון נתונים מ-API:

function UserProfile({ userId }: { userId: string }) {
  const [data, setData] = useState(null);
  const [loading, setLoading] = useState(true);
  const [error, setError] = useState<string | null>(null);

  useEffect(() => {
    setLoading(true);
    fetch(`/api/users/${userId}`)
      .then((res) => res.json())
      .then((data) => setData(data))
      .catch((err) => setError(err.message))
      .finally(() => setLoading(false));
  }, [userId]);

  if (loading) return <p>טוען...</p>;
  if (error) return <p>שגיאה: {error}</p>;
  return <div>{JSON.stringify(data)}</div>;
}

function ProductList() {
  const [data, setData] = useState(null);
  const [loading, setLoading] = useState(true);
  const [error, setError] = useState<string | null>(null);

  useEffect(() => {
    setLoading(true);
    fetch("/api/products")
      .then((res) => res.json())
      .then((data) => setData(data))
      .catch((err) => setError(err.message))
      .finally(() => setLoading(false));
  }, []);

  if (loading) return <p>טוען...</p>;
  if (error) return <p>שגיאה: {error}</p>;
  return <div>{JSON.stringify(data)}</div>;
}
  • הלוגיקה של טעינת נתונים חוזרת על עצמה
  • אם נרצה לשנות את אופן הטיפול בשגיאות, נצטרך לעדכן בכל מקום

מה זה הוק מותאם אישית?

  • הוק מותאם אישית הוא פונקציה שמתחילה בקידומת use ויכולה להשתמש בהוקים אחרים
  • הוא מאפשר לחלץ לוגיקה שחוזרת על עצמה לפונקציה אחת שניתנת לשימוש חוזר
  • כל קומפוננטה שמשתמשת בהוק מקבלת עותק עצמאי של ה-state

כללי הוקים - תזכורת

  • קראו להוקים רק ברמה העליונה (לא בתוך תנאים, לולאות או פונקציות מקוננות)
  • קראו להוקים רק מתוך קומפוננטות ריאקט או מתוך הוקים מותאמים אישית
  • שם ההוק חייב להתחיל ב-use

דוגמאות להוקים מותאמים אישית

הוק useFetch - שליפת נתונים

import { useState, useEffect } from "react";

interface UseFetchResult<T> {
  data: T | null;
  loading: boolean;
  error: string | null;
  refetch: () => void;
}

function useFetch<T>(url: string): UseFetchResult<T> {
  const [data, setData] = useState<T | null>(null);
  const [loading, setLoading] = useState(true);
  const [error, setError] = useState<string | null>(null);

  const fetchData = async () => {
    setLoading(true);
    setError(null);
    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 instanceof Error ? err.message : "שגיאה לא ידועה");
    } finally {
      setLoading(false);
    }
  };

  useEffect(() => {
    fetchData();
  }, [url]);

  return { data, loading, error, refetch: fetchData };
}

שימוש:

function UserProfile({ userId }: { userId: string }) {
  const { data, loading, error, refetch } = useFetch<User>(
    `/api/users/${userId}`
  );

  if (loading) return <p>טוען...</p>;
  if (error) return <p>שגיאה: {error} <button onClick={refetch}>נסה שוב</button></p>;
  if (!data) return null;

  return (
    <div>
      <h2>{data.name}</h2>
      <p>{data.email}</p>
    </div>
  );
}

הוק useToggle - מצב הפעלה/כיבוי

import { useState, useCallback } from "react";

function useToggle(initialValue = false) {
  const [value, setValue] = useState(initialValue);

  const toggle = useCallback(() => setValue((v) => !v), []);
  const setTrue = useCallback(() => setValue(true), []);
  const setFalse = useCallback(() => setValue(false), []);

  return { value, toggle, setTrue, setFalse };
}

שימוש:

function App() {
  const sidebar = useToggle(false);
  const darkMode = useToggle(true);

  return (
    <div className={darkMode.value ? "dark" : "light"}>
      <button onClick={darkMode.toggle}>
        {darkMode.value ? "מצב בהיר" : "מצב כהה"}
      </button>
      <button onClick={sidebar.toggle}>
        {sidebar.value ? "סגור תפריט" : "פתח תפריט"}
      </button>
      {sidebar.value && <nav>תפריט צד</nav>}
    </div>
  );
}

הוק useLocalStorage - שמירה ב-localStorage

import { useState, useEffect } from "react";

function useLocalStorage<T>(key: string, initialValue: T) {
  const [value, setValue] = useState<T>(() => {
    try {
      const stored = localStorage.getItem(key);
      return stored ? JSON.parse(stored) : initialValue;
    } catch {
      return initialValue;
    }
  });

  useEffect(() => {
    try {
      localStorage.setItem(key, JSON.stringify(value));
    } catch (error) {
      console.error("Failed to save to localStorage:", error);
    }
  }, [key, value]);

  const remove = () => {
    localStorage.removeItem(key);
    setValue(initialValue);
  };

  return [value, setValue, remove] as const;
}

שימוש:

function Settings() {
  const [theme, setTheme] = useLocalStorage("theme", "light");
  const [fontSize, setFontSize] = useLocalStorage("fontSize", 16);

  return (
    <div>
      <select value={theme} onChange={(e) => setTheme(e.target.value)}>
        <option value="light">בהיר</option>
        <option value="dark">כהה</option>
      </select>
      <input
        type="range"
        min={12}
        max={24}
        value={fontSize}
        onChange={(e) => setFontSize(Number(e.target.value))}
      />
      <p style={{ fontSize }}>טקסט לדוגמה</p>
    </div>
  );
}
  • ה-initializer function ב-useState (הפונקציה שמועברת ל-useState) רצה פעם אחת בלבד, ומאתחלת את הערך מ-localStorage
  • כל שינוי ב-value מסונכרן אוטומטית ל-localStorage דרך useEffect

הוק useDebounce - השהיית ערך

import { useState, useEffect } from "react";

function useDebounce<T>(value: T, delay: number): T {
  const [debouncedValue, setDebouncedValue] = useState(value);

  useEffect(() => {
    const timer = setTimeout(() => {
      setDebouncedValue(value);
    }, delay);

    return () => clearTimeout(timer);
  }, [value, delay]);

  return debouncedValue;
}

שימוש:

function SearchBar() {
  const [query, setQuery] = useState("");
  const debouncedQuery = useDebounce(query, 500);
  const { data, loading } = useFetch<string[]>(
    `/api/search?q=${debouncedQuery}`
  );

  return (
    <div>
      <input
        value={query}
        onChange={(e) => setQuery(e.target.value)}
        placeholder="חפש..."
      />
      {loading ? (
        <p>מחפש...</p>
      ) : (
        <ul>
          {data?.map((item, i) => (
            <li key={i}>{item}</li>
          ))}
        </ul>
      )}
    </div>
  );
}
  • כל פעם שה-value משתנה, ה-timeout הקודם מתבטל ומתחיל חדש
  • הערך המושהה מתעדכן רק אחרי שהמשתמש הפסיק לשנות את הערך למשך delay מילישניות

הוק useMediaQuery - תגובה לגודל מסך

import { useState, useEffect } from "react";

function useMediaQuery(query: string): boolean {
  const [matches, setMatches] = useState(() => {
    return window.matchMedia(query).matches;
  });

  useEffect(() => {
    const mediaQuery = window.matchMedia(query);

    const handler = (event: MediaQueryListEvent) => {
      setMatches(event.matches);
    };

    mediaQuery.addEventListener("change", handler);
    setMatches(mediaQuery.matches);

    return () => mediaQuery.removeEventListener("change", handler);
  }, [query]);

  return matches;
}

שימוש:

function ResponsiveLayout() {
  const isMobile = useMediaQuery("(max-width: 768px)");
  const isTablet = useMediaQuery("(min-width: 769px) and (max-width: 1024px)");
  const prefersDark = useMediaQuery("(prefers-color-scheme: dark)");

  return (
    <div className={prefersDark ? "dark" : "light"}>
      {isMobile ? (
        <MobileMenu />
      ) : isTablet ? (
        <TabletMenu />
      ) : (
        <DesktopMenu />
      )}
    </div>
  );
}

הוק useClickOutside - לחיצה מחוץ לאלמנט

import { useEffect, useRef } from "react";

function useClickOutside<T extends HTMLElement>(
  handler: () => void
) {
  const ref = useRef<T>(null);

  useEffect(() => {
    const handleClick = (event: MouseEvent) => {
      if (ref.current && !ref.current.contains(event.target as Node)) {
        handler();
      }
    };

    document.addEventListener("mousedown", handleClick);
    return () => document.removeEventListener("mousedown", handleClick);
  }, [handler]);

  return ref;
}

שימוש:

function Dropdown() {
  const [isOpen, setIsOpen] = useState(false);
  const dropdownRef = useClickOutside<HTMLDivElement>(() => {
    setIsOpen(false);
  });

  return (
    <div ref={dropdownRef}>
      <button onClick={() => setIsOpen(!isOpen)}>תפריט</button>
      {isOpen && (
        <ul>
          <li>אפשרות 1</li>
          <li>אפשרות 2</li>
          <li>אפשרות 3</li>
        </ul>
      )}
    </div>
  );
}

הוק useWindowSize - גודל חלון

import { useState, useEffect } from "react";

interface WindowSize {
  width: number;
  height: number;
}

function useWindowSize(): WindowSize {
  const [size, setSize] = useState<WindowSize>({
    width: window.innerWidth,
    height: window.innerHeight,
  });

  useEffect(() => {
    const handleResize = () => {
      setSize({
        width: window.innerWidth,
        height: window.innerHeight,
      });
    };

    window.addEventListener("resize", handleResize);
    return () => window.removeEventListener("resize", handleResize);
  }, []);

  return size;
}

הוק usePrevious - ערך קודם

import { useRef, useEffect } from "react";

function usePrevious<T>(value: T): T | undefined {
  const ref = useRef<T | undefined>(undefined);

  useEffect(() => {
    ref.current = value;
  }, [value]);

  return ref.current;
}

שימוש:

function PriceTracker({ price }: { price: number }) {
  const previousPrice = usePrevious(price);

  const direction =
    previousPrice === undefined
      ? "neutral"
      : price > previousPrice
      ? "up"
      : price < previousPrice
      ? "down"
      : "neutral";

  return (
    <div>
      <p>מחיר: {price} ש"ח</p>
      {direction === "up" && <span style={{ color: "green" }}>עלה</span>}
      {direction === "down" && <span style={{ color: "red" }}>ירד</span>}
    </div>
  );
}

הרכבת הוקים - Composition

אחד היתרונות הגדולים של הוקים מותאמים אישית הוא שאפשר להשתמש בהוק אחד בתוך הוק אחר:

function useSearchWithDebounce(url: string, delay = 500) {
  const [query, setQuery] = useState("");
  const debouncedQuery = useDebounce(query, delay);
  const { data, loading, error } = useFetch<string[]>(
    debouncedQuery ? `${url}?q=${debouncedQuery}` : ""
  );

  return {
    query,
    setQuery,
    results: data ?? [],
    loading,
    error,
  };
}

ארגון הוקים בפרויקט

מבנה תיקיות מומלץ:

src/
  hooks/
    useDebounce.ts
    useFetch.ts
    useLocalStorage.ts
    useToggle.ts
    useClickOutside.ts
    useMediaQuery.ts
    index.ts

קובץ index.ts לייצוא מרוכז:

export { useDebounce } from "./useDebounce";
export { useFetch } from "./useFetch";
export { useLocalStorage } from "./useLocalStorage";
export { useToggle } from "./useToggle";
export { useClickOutside } from "./useClickOutside";
export { useMediaQuery } from "./useMediaQuery";

שימוש:

import { useDebounce, useFetch, useToggle } from "@/hooks";

בדיקות להוקים מותאמים אישית

אפשר לבדוק הוקים באמצעות הספרייה @testing-library/react:

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

describe("useToggle", () => {
  it("should start with initial value", () => {
    const { result } = renderHook(() => useToggle(false));
    expect(result.current.value).toBe(false);
  });

  it("should toggle value", () => {
    const { result } = renderHook(() => useToggle(false));

    act(() => {
      result.current.toggle();
    });

    expect(result.current.value).toBe(true);

    act(() => {
      result.current.toggle();
    });

    expect(result.current.value).toBe(false);
  });

  it("should set true", () => {
    const { result } = renderHook(() => useToggle(false));

    act(() => {
      result.current.setTrue();
    });

    expect(result.current.value).toBe(true);
  });
});
  • הפונקציה renderHook מאפשרת להריץ הוק בלי קומפוננטה
  • act עוטפת פעולות שמשנות state
  • result.current מכיל את הערך העדכני שהוק מחזיר

סיכום

  • הוקים מותאמים אישית מאפשרים חילוץ לוגיקה חוזרת ושיתוף שלה בין קומפוננטות
  • שם ההוק חייב להתחיל ב-use כדי שריאקט תוכל לאכוף את כללי ההוקים
  • כל קומפוננטה שמשתמשת בהוק מקבלת עותק עצמאי של ה-state
  • אפשר להרכיב הוקים אחד על השני ליצירת לוגיקה מורכבת
  • דוגמאות נפוצות: useFetch, useLocalStorage, useDebounce, useToggle, useClickOutside, useMediaQuery
  • מומלץ לארגן הוקים בתיקייה ייעודית עם קובץ index לייצוא מרוכז
  • אפשר לבדוק הוקים באמצעות renderHook מ-testing-library