לדלג לתוכן

7.2 הוקים מותאמים אישית פתרון

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


פתרון תרגיל 1 - הוק useCounter

import { useState, useCallback } from "react";

interface UseCounterOptions {
  min?: number;
  max?: number;
}

function useCounter(initialValue = 0, options: UseCounterOptions = {}) {
  const { min = -Infinity, max = Infinity } = options;
  const [count, setCount] = useState(
    Math.min(Math.max(initialValue, min), max)
  );

  const increment = useCallback(() => {
    setCount((prev) => Math.min(prev + 1, max));
  }, [max]);

  const decrement = useCallback(() => {
    setCount((prev) => Math.max(prev - 1, min));
  }, [min]);

  const reset = useCallback(() => {
    setCount(Math.min(Math.max(initialValue, min), max));
  }, [initialValue, min, max]);

  const set = useCallback(
    (value: number) => {
      setCount(Math.min(Math.max(value, min), max));
    },
    [min, max]
  );

  return { count, increment, decrement, reset, setCount: set };
}

// שימוש
function QuantitySelector() {
  const { count, increment, decrement, reset } = useCounter(1, {
    min: 1,
    max: 10,
  });

  return (
    <div>
      <h3>בחר כמות</h3>
      <button onClick={decrement} disabled={count <= 1}>-</button>
      <span style={{ margin: "0 15px" }}>{count}</span>
      <button onClick={increment} disabled={count >= 10}>+</button>
      <button onClick={reset}>אפס</button>
    </div>
  );
}

הסבר:
- ההוק מקבל ערך התחלתי ואובייקט אפשרויות עם min ו-max
- בכל פעולה אנחנו מוודאים שהערך לא חורג מהגבולות באמצעות Math.min ו-Math.max
- כל הפונקציות עטופות ב-useCallback כדי שלא יגרמו לרנדרים מיותרים


פתרון תרגיל 2 - הוק useForm

import { useState, useCallback, useMemo } from "react";

type ValidationErrors<T> = Partial<Record<keyof T, string>>;
type ValidateFn<T> = (values: T) => ValidationErrors<T>;

function useForm<T extends Record<string, any>>(
  initialValues: T,
  validate: ValidateFn<T>
) {
  const [values, setValues] = useState<T>(initialValues);
  const [errors, setErrors] = useState<ValidationErrors<T>>({});
  const [touched, setTouched] = useState<Partial<Record<keyof T, boolean>>>({});

  const handleChange = useCallback(
    (e: React.ChangeEvent<HTMLInputElement>) => {
      const { name, value } = e.target;
      setValues((prev) => {
        const newValues = { ...prev, [name]: value };
        const newErrors = validate(newValues);
        setErrors(newErrors);
        return newValues;
      });
      setTouched((prev) => ({ ...prev, [name]: true }));
    },
    [validate]
  );

  const handleSubmit = useCallback(
    (onSubmit: (values: T) => void) => {
      return (e: React.FormEvent) => {
        e.preventDefault();
        const validationErrors = validate(values);
        setErrors(validationErrors);

        const allTouched: Partial<Record<keyof T, boolean>> = {};
        for (const key in values) {
          allTouched[key] = true;
        }
        setTouched(allTouched);

        if (Object.keys(validationErrors).length === 0) {
          onSubmit(values);
        }
      };
    },
    [values, validate]
  );

  const reset = useCallback(() => {
    setValues(initialValues);
    setErrors({});
    setTouched({});
  }, [initialValues]);

  const isValid = useMemo(() => {
    return Object.keys(validate(values)).length === 0;
  }, [values, validate]);

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

// ולידציה
interface RegistrationForm {
  name: string;
  email: string;
  password: string;
  confirmPassword: string;
}

const validateRegistration: ValidateFn<RegistrationForm> = (values) => {
  const errors: ValidationErrors<RegistrationForm> = {};

  if (!values.name || values.name.length < 2) {
    errors.name = "שם חייב להכיל לפחות 2 תווים";
  }

  if (!values.email || !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(values.email)) {
    errors.email = "יש להזין כתובת אימייל תקינה";
  }

  if (!values.password || values.password.length < 6) {
    errors.password = "סיסמה חייבת להכיל לפחות 6 תווים";
  }

  if (values.password !== values.confirmPassword) {
    errors.confirmPassword = "הסיסמאות לא תואמות";
  }

  return errors;
};

// שימוש
function RegistrationPage() {
  const { values, errors, touched, handleChange, handleSubmit, reset, isValid } =
    useForm<RegistrationForm>(
      { name: "", email: "", password: "", confirmPassword: "" },
      validateRegistration
    );

  const onSubmit = (formValues: RegistrationForm) => {
    console.log("נשלח:", formValues);
    alert("הרשמה בוצעה בהצלחה!");
    reset();
  };

  return (
    <form onSubmit={handleSubmit(onSubmit)}>
      <h2>הרשמה</h2>

      <div>
        <label>שם</label>
        <input name="name" value={values.name} onChange={handleChange} />
        {touched.name && errors.name && <p style={{ color: "red" }}>{errors.name}</p>}
      </div>

      <div>
        <label>אימייל</label>
        <input name="email" type="email" value={values.email} onChange={handleChange} />
        {touched.email && errors.email && <p style={{ color: "red" }}>{errors.email}</p>}
      </div>

      <div>
        <label>סיסמה</label>
        <input name="password" type="password" value={values.password} onChange={handleChange} />
        {touched.password && errors.password && <p style={{ color: "red" }}>{errors.password}</p>}
      </div>

      <div>
        <label>אישור סיסמה</label>
        <input
          name="confirmPassword"
          type="password"
          value={values.confirmPassword}
          onChange={handleChange}
        />
        {touched.confirmPassword && errors.confirmPassword && (
          <p style={{ color: "red" }}>{errors.confirmPassword}</p>
        )}
      </div>

      <button type="submit" disabled={!isValid}>הירשם</button>
      <button type="button" onClick={reset}>נקה</button>
    </form>
  );
}

הסבר:
- ההוק מנהל values, errors ו-touched (האם השדה "נגע") במקום אחד
- הפונקציה handleSubmit מחזירה event handler שבודק ולידציה לפני קריאה ל-onSubmit
- שגיאות מוצגות רק לשדות שנגעו בהם (touched), כדי לא להציג שגיאות בטעינה ראשונה
- isValid מחושב עם useMemo ומתעדכן אוטומטית


פתרון תרגיל 3 - הוק useAsync

import { useState, useCallback, useRef, useEffect } from "react";

interface UseAsyncState<T> {
  data: T | null;
  loading: boolean;
  error: string | null;
}

function useAsync<T>(asyncFn: (...args: any[]) => Promise<T>) {
  const [state, setState] = useState<UseAsyncState<T>>({
    data: null,
    loading: false,
    error: null,
  });
  const mountedRef = useRef(true);

  useEffect(() => {
    mountedRef.current = true;
    return () => {
      mountedRef.current = false;
    };
  }, []);

  const execute = useCallback(
    async (...args: any[]) => {
      setState({ data: null, loading: true, error: null });
      try {
        const result = await asyncFn(...args);
        if (mountedRef.current) {
          setState({ data: result, loading: false, error: null });
        }
        return result;
      } catch (err) {
        if (mountedRef.current) {
          setState({
            data: null,
            loading: false,
            error: err instanceof Error ? err.message : "שגיאה לא ידועה",
          });
        }
        throw err;
      }
    },
    [asyncFn]
  );

  const reset = useCallback(() => {
    setState({ data: null, loading: false, error: null });
  }, []);

  return { ...state, execute, reset };
}

// פונקציה אסינכרונית מדומה
const fetchUser = async (id: number) => {
  await new Promise((resolve) => setTimeout(resolve, 1500));
  if (id === 0) throw new Error("משתמש לא נמצא");
  return { id, name: `משתמש ${id}`, email: `user${id}@example.com` };
};

// שימוש
function UserLoader() {
  const [userId, setUserId] = useState(1);
  const { data, loading, error, execute, reset } = useAsync(fetchUser);

  const handleLoad = () => {
    execute(userId);
  };

  return (
    <div>
      <h2>טעינת משתמש</h2>
      <input
        type="number"
        value={userId}
        onChange={(e) => setUserId(Number(e.target.value))}
      />
      <button onClick={handleLoad} disabled={loading}>
        {loading ? "טוען..." : "טען משתמש"}
      </button>
      <button onClick={reset}>אפס</button>

      {error && <p style={{ color: "red" }}>שגיאה: {error}</p>}
      {data && (
        <div>
          <p>שם: {data.name}</p>
          <p>אימייל: {data.email}</p>
        </div>
      )}
    </div>
  );
}

הסבר:
- mountedRef עוקב אחרי מצב הקומפוננטה - מונע עדכון state אחרי unmount
- הפונקציה execute מקבלת ארגומנטים ומעבירה אותם לפונקציה האסינכרונית
- ה-state מנוהל באובייקט אחד כדי למנוע מצבים לא עקביים


פתרון תרגיל 4 - הוק useKeyPress

import { useEffect, useCallback } from "react";

interface KeyCombo {
  key: string;
  ctrl?: boolean;
  shift?: boolean;
  alt?: boolean;
}

function useKeyPress(
  keyCombo: KeyCombo | string,
  callback: () => void,
  enabled = true
) {
  const combo: KeyCombo =
    typeof keyCombo === "string" ? { key: keyCombo } : keyCombo;

  const handleKeyDown = useCallback(
    (event: KeyboardEvent) => {
      if (!enabled) return;

      const matchesKey = event.key === combo.key;
      const matchesCtrl = combo.ctrl ? event.ctrlKey || event.metaKey : !event.ctrlKey && !event.metaKey;
      const matchesShift = combo.shift ? event.shiftKey : !event.shiftKey;
      const matchesAlt = combo.alt ? event.altKey : !event.altKey;

      if (matchesKey && matchesCtrl && matchesShift && matchesAlt) {
        event.preventDefault();
        callback();
      }
    },
    [combo.key, combo.ctrl, combo.shift, combo.alt, callback, enabled]
  );

  useEffect(() => {
    if (!enabled) return;
    window.addEventListener("keydown", handleKeyDown);
    return () => window.removeEventListener("keydown", handleKeyDown);
  }, [handleKeyDown, enabled]);
}

// שימוש
function KeyboardApp() {
  const [isModalOpen, setIsModalOpen] = useState(false);
  const [selectedIndex, setSelectedIndex] = useState(0);
  const [saved, setSaved] = useState(false);

  const items = ["פריט 1", "פריט 2", "פריט 3", "פריט 4", "פריט 5"];

  useKeyPress("Escape", () => setIsModalOpen(false), isModalOpen);

  useKeyPress({ key: "s", ctrl: true }, () => {
    setSaved(true);
    setTimeout(() => setSaved(false), 2000);
  });

  useKeyPress("ArrowDown", () => {
    setSelectedIndex((prev) => Math.min(prev + 1, items.length - 1));
  });

  useKeyPress("ArrowUp", () => {
    setSelectedIndex((prev) => Math.max(prev - 1, 0));
  });

  return (
    <div>
      <h2>ניווט מקלדת</h2>
      {saved && <p style={{ color: "green" }}>נשמר!</p>}
      <button onClick={() => setIsModalOpen(true)}>פתח חלון</button>

      <ul>
        {items.map((item, index) => (
          <li
            key={index}
            style={{
              backgroundColor: index === selectedIndex ? "#ddd" : "transparent",
              padding: "5px",
            }}
          >
            {item}
          </li>
        ))}
      </ul>

      <p>השתמש בחיצים למעלה/למטה לניווט, Ctrl+S לשמירה</p>

      {isModalOpen && (
        <div
          style={{
            position: "fixed",
            top: 0,
            left: 0,
            right: 0,
            bottom: 0,
            backgroundColor: "rgba(0,0,0,0.5)",
            display: "flex",
            alignItems: "center",
            justifyContent: "center",
          }}
        >
          <div style={{ backgroundColor: "white", padding: "20px" }}>
            <h3>חלון מודאלי</h3>
            <p>לחץ Escape לסגירה</p>
          </div>
        </div>
      )}
    </div>
  );
}

הסبר:
- ההוק מקבל הגדרת מקש (מחרוזת פשוטה או אובייקט עם modifier keys)
- הפרמטר enabled מאפשר להפעיל ולכבות את ההאזנה (לדוגמה, Escape עובד רק כש-modal פתוח)
- ב-Ctrl בודקים גם metaKey כדי לתמוך ב-Mac (Command)


פתרון תרגיל 5 - הוק useIntersectionObserver

import { useState, useEffect, useRef, useCallback } from "react";

interface UseIntersectionOptions {
  threshold?: number;
  rootMargin?: string;
  triggerOnce?: boolean;
}

function useIntersectionObserver(options: UseIntersectionOptions = {}) {
  const { threshold = 0, rootMargin = "0px", triggerOnce = false } = options;
  const [isIntersecting, setIsIntersecting] = useState(false);
  const ref = useRef<HTMLDivElement>(null);
  const observerRef = useRef<IntersectionObserver | null>(null);

  useEffect(() => {
    if (!ref.current) return;

    observerRef.current = new IntersectionObserver(
      ([entry]) => {
        setIsIntersecting(entry.isIntersecting);
        if (entry.isIntersecting && triggerOnce) {
          observerRef.current?.disconnect();
        }
      },
      { threshold, rootMargin }
    );

    observerRef.current.observe(ref.current);

    return () => observerRef.current?.disconnect();
  }, [threshold, rootMargin, triggerOnce]);

  return { ref, isIntersecting };
}

// רשימה אינסופית
function InfiniteList() {
  const [items, setItems] = useState<string[]>(
    Array.from({ length: 10 }, (_, i) => `פריט ${i + 1}`)
  );
  const [loading, setLoading] = useState(false);
  const { ref: loadMoreRef, isIntersecting } = useIntersectionObserver({
    threshold: 0.5,
  });

  useEffect(() => {
    if (isIntersecting && !loading) {
      setLoading(true);
      setTimeout(() => {
        setItems((prev) => {
          const start = prev.length;
          const newItems = Array.from(
            { length: 10 },
            (_, i) => `פריט ${start + i + 1}`
          );
          return [...prev, ...newItems];
        });
        setLoading(false);
      }, 1000);
    }
  }, [isIntersecting, loading]);

  return (
    <div>
      <h2>רשימה אינסופית</h2>
      <ul>
        {items.map((item, index) => (
          <li key={index} style={{ padding: "10px" }}>
            {item}
          </li>
        ))}
      </ul>
      <div ref={loadMoreRef} style={{ padding: "20px", textAlign: "center" }}>
        {loading ? "טוען עוד..." : "גלול למטה לטעינת עוד פריטים"}
      </div>
    </div>
  );
}

// אנימציית כניסה
function AnimatedSection({ children }: { children: React.ReactNode }) {
  const { ref, isIntersecting } = useIntersectionObserver({
    threshold: 0.2,
    triggerOnce: true,
  });

  return (
    <div
      ref={ref}
      style={{
        opacity: isIntersecting ? 1 : 0,
        transform: isIntersecting ? "translateY(0)" : "translateY(30px)",
        transition: "opacity 0.6s ease, transform 0.6s ease",
      }}
    >
      {children}
    </div>
  );
}

function AnimatedPage() {
  const sections = [
    "סקציה ראשונה - ברוכים הבאים",
    "סקציה שנייה - השירותים שלנו",
    "סקציה שלישית - צרו קשר",
    "סקציה רביעית - אודותינו",
    "סקציה חמישית - המלצות",
  ];

  return (
    <div>
      <h2>אנימציית כניסה</h2>
      {sections.map((section, index) => (
        <AnimatedSection key={index}>
          <div
            style={{
              padding: "60px 20px",
              margin: "20px 0",
              backgroundColor: `hsl(${index * 60}, 70%, 90%)`,
              borderRadius: "8px",
            }}
          >
            <h3>{section}</h3>
            <p>תוכן לדוגמה של הסקציה</p>
          </div>
        </AnimatedSection>
      ))}
    </div>
  );
}

הסבר:
- ההוק מחזיר ref שצריך לחבר לאלמנט, ו-isIntersecting שאומר אם האלמנט בתצוגה
- האפשרות triggerOnce גורמת ל-observer לנתק אחרי הפעם הראשונה (שימושי לאנימציות)
- ברשימה אינסופית, כשהאלמנט התחתון נכנס לתצוגה, אנחנו טוענים עוד פריטים
- באנימציית כניסה, כל סקציה מופיעה עם fade-in ותנועה מלמטה


פתרון תרגיל 6 - הוק useWebSocket (מתקדם)

import { useState, useRef, useCallback, useEffect } from "react";

type ConnectionStatus = "connecting" | "connected" | "disconnected" | "error";

interface UseWebSocketOptions {
  reconnect?: boolean;
  maxRetries?: number;
  retryDelay?: number;
}

function useWebSocket(url: string, options: UseWebSocketOptions = {}) {
  const { reconnect = true, maxRetries = 5, retryDelay = 1000 } = options;
  const [lastMessage, setLastMessage] = useState<string | null>(null);
  const [status, setStatus] = useState<ConnectionStatus>("disconnected");
  const wsRef = useRef<WebSocket | null>(null);
  const retriesRef = useRef(0);
  const retryTimeoutRef = useRef<number | null>(null);

  const connect = useCallback(() => {
    if (wsRef.current?.readyState === WebSocket.OPEN) return;

    setStatus("connecting");
    const ws = new WebSocket(url);

    ws.onopen = () => {
      setStatus("connected");
      retriesRef.current = 0;
    };

    ws.onmessage = (event) => {
      setLastMessage(event.data);
    };

    ws.onerror = () => {
      setStatus("error");
    };

    ws.onclose = () => {
      setStatus("disconnected");
      wsRef.current = null;

      if (reconnect && retriesRef.current < maxRetries) {
        const delay = retryDelay * Math.pow(2, retriesRef.current);
        retriesRef.current++;
        retryTimeoutRef.current = window.setTimeout(() => {
          connect();
        }, delay);
      }
    };

    wsRef.current = ws;
  }, [url, reconnect, maxRetries, retryDelay]);

  const disconnect = useCallback(() => {
    if (retryTimeoutRef.current) {
      clearTimeout(retryTimeoutRef.current);
      retryTimeoutRef.current = null;
    }
    retriesRef.current = maxRetries;
    wsRef.current?.close();
    wsRef.current = null;
    setStatus("disconnected");
  }, [maxRetries]);

  const sendMessage = useCallback((message: string) => {
    if (wsRef.current?.readyState === WebSocket.OPEN) {
      wsRef.current.send(message);
    } else {
      console.error("WebSocket is not connected");
    }
  }, []);

  useEffect(() => {
    return () => {
      if (retryTimeoutRef.current) {
        clearTimeout(retryTimeoutRef.current);
      }
      wsRef.current?.close();
    };
  }, []);

  return {
    sendMessage,
    lastMessage,
    connectionStatus: status,
    connect,
    disconnect,
  };
}

// קומפוננטת צ'אט
function ChatApp() {
  const [messages, setMessages] = useState<
    { text: string; sender: "me" | "other" }[]
  >([]);
  const [input, setInput] = useState("");

  const { sendMessage, lastMessage, connectionStatus, connect, disconnect } =
    useWebSocket("ws://localhost:8080/chat");

  useEffect(() => {
    if (lastMessage) {
      setMessages((prev) => [...prev, { text: lastMessage, sender: "other" }]);
    }
  }, [lastMessage]);

  const handleSend = () => {
    if (!input.trim()) return;
    sendMessage(input);
    setMessages((prev) => [...prev, { text: input, sender: "me" }]);
    setInput("");
  };

  const statusColors: Record<ConnectionStatus, string> = {
    connected: "green",
    connecting: "orange",
    disconnected: "gray",
    error: "red",
  };

  const statusLabels: Record<ConnectionStatus, string> = {
    connected: "מחובר",
    connecting: "מתחבר...",
    disconnected: "מנותק",
    error: "שגיאה",
  };

  return (
    <div>
      <h2>צ'אט</h2>
      <div>
        <span style={{ color: statusColors[connectionStatus] }}>
          {statusLabels[connectionStatus]}
        </span>
        {connectionStatus === "disconnected" && (
          <button onClick={connect}>התחבר</button>
        )}
        {connectionStatus === "connected" && (
          <button onClick={disconnect}>התנתק</button>
        )}
      </div>

      <div
        style={{
          border: "1px solid #ccc",
          height: "300px",
          overflowY: "auto",
          padding: "10px",
        }}
      >
        {messages.map((msg, index) => (
          <div
            key={index}
            style={{
              textAlign: msg.sender === "me" ? "right" : "left",
              margin: "5px 0",
            }}
          >
            <span
              style={{
                backgroundColor: msg.sender === "me" ? "#dcf8c6" : "#e8e8e8",
                padding: "5px 10px",
                borderRadius: "10px",
                display: "inline-block",
              }}
            >
              {msg.text}
            </span>
          </div>
        ))}
      </div>

      <div>
        <input
          value={input}
          onChange={(e) => setInput(e.target.value)}
          onKeyDown={(e) => e.key === "Enter" && handleSend()}
          placeholder="הקלד הודעה..."
          disabled={connectionStatus !== "connected"}
        />
        <button onClick={handleSend} disabled={connectionStatus !== "connected"}>
          שלח
        </button>
      </div>
    </div>
  );
}

הסبر:
- ההוק מנהל חיבור WebSocket עם תמיכה ב-reconnect אוטומטי עם exponential backoff
- כל ניסיון חיבור מחדש מכפיל את זמן ההמתנה (1s, 2s, 4s, 8s...)
- כשמתנתקים ידנית (disconnect), מגדירים retries למקסימום כדי למנוע reconnect אוטומטי
- ניקוי מלא ב-useEffect cleanup - סגירת WebSocket וביטול timeout


תשובות לשאלות

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

  2. שני instances של אותו הוק: כל instance מקבל state עצמאי לחלוטין. הם לא חולקים state. זה אחד ההבדלים העיקריים בין הוקים ל-context - כל קריאה להוק יוצרת "עותק" חדש.

  3. קידומת use: הקידומת use היא לא רק קונבנציה - ריאקט משתמשת בה כדי לזהות הוקים ולאכוף את הכללים (למשל, שלא קוראים לה בתוך תנאי). ה-linter של ריאקט (eslint-plugin-react-hooks) מסתמך על השם כדי לבדוק שימוש נכון. בלי הקידומת, ריאקט לא תזהה את הפונקציה כהוק והכללים לא ייאכפו.

  4. בדיקת הוקים עם DOM API: אפשר להשתמש ב-jest.mock כדי לדמות APIs של הדפדפן, או ב-jsdom שמגיע עם jest שמספק חלק מה-APIs. לדוגמה, עבור matchMedia אפשר ליצור mock שמחזיר אובייקט עם matches ו-addEventListener. גם renderHook מ-testing-library מאפשר לרנדר את ההוק בסביבה מבוקרת.

  5. אובייקט מול מערך: אובייקט מאפשר destructuring עם שמות ברורים ולבחור רק מה שצריך (const { data, loading } = useAsync(fn)). מערך מאפשר שינוי שמות בקלות ושימוש במספר instances (const [count1, setCount1] = useCounter(0); const [count2, setCount2] = useCounter(0)). הקונבנציה: מערך כשיש 2-3 ערכים (כמו useState), אובייקט כשיש יותר.