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";
שימוש:
בדיקות להוקים מותאמים אישית¶
אפשר לבדוק הוקים באמצעות הספרייה @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