Custom Hooks

מבוא

Custom Hook הוא פונקציה ששמה מתחיל ב-use ומשתמשת בתוכה ב-Hooks אחרים. תפקידו לחלץ לוגיקה החוזרת על עצמה - כמו שליפת נתונים מהשרת או האזנה לגודל החלון - למקום אחד, כדי שאפשר יהיה לעשות בה שימוש חוזר במספר קומפוננטות בלי לשכפל קוד. זהו אחד הכלים העוצמתיים ביותר לשמירה על קוד נקי ומאורגן ב-React.

// במקום לשכפל קוד...
function Component1() {
    const [loading, setLoading] = useState(true);
    const [data, setData] = useState(null);
    useEffect(() => { /* fetch logic */ }, []);
}

function Component2() {
    const [loading, setLoading] = useState(true);
    const [data, setData] = useState(null);
    useEffect(() => { /* same fetch logic */ }, []);
}

// ...יוצרים Custom Hook!
function useFetch(url) {
    const [loading, setLoading] = useState(true);
    const [data, setData] = useState(null);
    useEffect(() => { /* fetch logic */ }, [url]);
    return { loading, data };
}

function Component1() {
    const { loading, data } = useFetch('/api/data1');
}

כללים ל-Custom Hooks

  1. שם מתחיל ב-use - חובה! (useMyHook, useFetch, useAuth)
  2. קורא ל-Hooks אחרים - useState, useEffect, וכו'
  3. מחזיר ערכים - מה שהקומפוננטה צריכה
  4. לוגיקה טהורה - ללא JSX (זה לא קומפוננטה!)

דוגמאות Custom Hooks

1. useFetch - קריאות API

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

    useEffect(() => {
        const controller = new AbortController();

        async function fetchData() {
            try {
                setLoading(true);
                setError(null);

                const response = await fetch(url, {
                    signal: controller.signal
                });

                if (!response.ok) {
                    throw new Error(`HTTP error! status: ${response.status}`);
                }

                const json = await response.json();
                setData(json);
            } catch (err) {
                if (err.name !== 'AbortError') {
                    setError(err.message);
                }
            } finally {
                setLoading(false);
            }
        }

        fetchData();

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

    return { data, loading, error };
}

// שימוש
function UserList() {
    const { data: users, loading, error } = useFetch('/api/users');

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

    return (
        <ul>
            {users.map(user => <li key={user.id}>{user.name}</li>)}
        </ul>
    );
}

2. useLocalStorage - שמירה מקומית

function useLocalStorage(key, initialValue) {
    // קריאה מ-localStorage בטעינה
    const [value, setValue] = useState(() => {
        try {
            const item = localStorage.getItem(key);
            return item ? JSON.parse(item) : initialValue;
        } catch {
            return initialValue;
        }
    });

    // שמירה ל-localStorage בכל שינוי
    useEffect(() => {
        try {
            localStorage.setItem(key, JSON.stringify(value));
        } catch (error) {
            console.error('Error saving to localStorage:', error);
        }
    }, [key, value]);

    return [value, setValue];
}

// שימוש
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" 
                value={fontSize}
                onChange={e => setFontSize(Number(e.target.value))}
                min="12" max="24"
            />
        </div>
    );
}

3. useToggle - החלפת מצב

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 Modal() {
    const { value: isOpen, toggle, setFalse: close } = useToggle();

    return (
        <>
            <button onClick={toggle}>פתח מודל</button>
            {isOpen && (
                <div className="modal">
                    <p>תוכן המודל</p>
                    <button onClick={close}>סגור</button>
                </div>
            )}
        </>
    );
}

4. useDebounce - השהייה

function useDebounce(value, delay = 300) {
    const [debouncedValue, setDebouncedValue] = useState(value);

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

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

    return debouncedValue;
}

// שימוש - חיפוש עם השהייה
function SearchInput() {
    const [query, setQuery] = useState('');
    const debouncedQuery = useDebounce(query, 500);
    const { data: results } = useFetch(
        debouncedQuery ? `/api/search?q=${debouncedQuery}` : null
    );

    return (
        <div>
            <input 
                value={query}
                onChange={e => setQuery(e.target.value)}
                placeholder="חפש..."
            />
            {results && <SearchResults results={results} />}
        </div>
    );
}

5. useWindowSize - גודל חלון

function useWindowSize() {
    const [size, setSize] = useState({
        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;
}

// שימוש
function ResponsiveComponent() {
    const { width } = useWindowSize();

    return (
        <div>
            {width < 768 ? <MobileLayout /> : <DesktopLayout />}
        </div>
    );
}

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

function useClickOutside(ref, callback) {
    useEffect(() => {
        const handleClick = (event) => {
            if (ref.current && !ref.current.contains(event.target)) {
                callback();
            }
        };

        document.addEventListener('mousedown', handleClick);
        return () => document.removeEventListener('mousedown', handleClick);
    }, [ref, callback]);
}

// שימוש
function Dropdown() {
    const [isOpen, setIsOpen] = useState(false);
    const dropdownRef = useRef(null);

    useClickOutside(dropdownRef, () => setIsOpen(false));

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

7. usePrevious - ערך קודם

function usePrevious(value) {
    const ref = useRef();

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

    return ref.current;
}

// שימוש
function Counter() {
    const [count, setCount] = useState(0);
    const prevCount = usePrevious(count);

    return (
        <div>
            <p>נוכחי: {count}</p>
            <p>קודם: {prevCount}</p>
            <button onClick={() => setCount(c => c + 1)}>+1</button>
        </div>
    );
}

8. useForm - ניהול טפסים

function useForm(initialValues, validate) {
    const [values, setValues] = useState(initialValues);
    const [errors, setErrors] = useState({});
    const [touched, setTouched] = useState({});

    const handleChange = (name) => (e) => {
        const value = e.target.type === 'checkbox' 
            ? e.target.checked 
            : e.target.value;

        setValues(prev => ({ ...prev, [name]: value }));
    };

    const handleBlur = (name) => () => {
        setTouched(prev => ({ ...prev, [name]: true }));

        if (validate) {
            const fieldError = validate({ [name]: values[name] });
            setErrors(prev => ({ ...prev, ...fieldError }));
        }
    };

    const handleSubmit = (onSubmit) => (e) => {
        e.preventDefault();

        if (validate) {
            const allErrors = validate(values);
            setErrors(allErrors);

            if (Object.keys(allErrors).length === 0) {
                onSubmit(values);
            }
        } else {
            onSubmit(values);
        }
    };

    const reset = () => {
        setValues(initialValues);
        setErrors({});
        setTouched({});
    };

    return {
        values,
        errors,
        touched,
        handleChange,
        handleBlur,
        handleSubmit,
        reset
    };
}

// שימוש
function LoginForm() {
    const validate = (values) => {
        const errors = {};
        if (!values.email?.includes('@')) errors.email = 'אימייל לא תקין';
        if (values.password?.length < 6) errors.password = 'לפחות 6 תווים';
        return errors;
    };

    const { values, errors, handleChange, handleBlur, handleSubmit } = useForm(
        { email: '', password: '' },
        validate
    );

    return (
        <form onSubmit={handleSubmit(data => console.log(data))}>
            <input 
                value={values.email}
                onChange={handleChange('email')}
                onBlur={handleBlur('email')}
            />
            {errors.email && <span>{errors.email}</span>}

            <input 
                type="password"
                value={values.password}
                onChange={handleChange('password')}
                onBlur={handleBlur('password')}
            />
            {errors.password && <span>{errors.password}</span>}

            <button type="submit">התחבר</button>
        </form>
    );
}

סיכום

Hook שימוש
useFetch קריאות API
useLocalStorage שמירה מקומית
useToggle מצב בוליאני
useDebounce השהיית ערך
useWindowSize גודל חלון
useClickOutside לחיצה מחוץ
usePrevious ערך קודם
useForm ניהול טפסים

הקודם: ← Context API
הבא: React Patterns →

בדקו את עצמכם

נסו לענות לבד לפני שאתם פותחים את התשובה.

  1. מה חייב להיות בשם של Custom Hook?

    1. אות גדולה
    2. קידומת use, כמו useFetch
    3. המילה Hook
    4. סיומת .hook
    הצגת התשובה

    תשובה ב. הקידומת מאפשרת ל-React ולכלי lint לאכוף את חוקי ה-Hooks.

  2. האם שתי קומפוננטות שמשתמשות באותו Custom Hook חולקות state?

    1. תלוי בשם
    2. רק אם הן אחיות
    3. כן
    4. לא: כל קריאה יוצרת state נפרד
    הצגת התשובה

    תשובה ד. Hook משתף לוגיקה, לא נתונים. לשיתוף נתונים משתמשים ב-Context.