לדלג לתוכן

7.1 useRef, useMemo ו useCallback פתרון

פתרון - useRef, useMemo ו-useCallback


פתרון תרגיל 1 - טיימר עם useRef

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

function Timer() {
  const [time, setTime] = useState(0);
  const [isRunning, setIsRunning] = useState(false);
  const [laps, setLaps] = useState<number[]>([]);
  const intervalRef = useRef<number | null>(null);

  const formatTime = (seconds: number) => {
    const mins = Math.floor(seconds / 60);
    const secs = seconds % 60;
    return `${String(mins).padStart(2, "0")}:${String(secs).padStart(2, "0")}`;
  };

  const start = () => {
    if (isRunning) return;
    setIsRunning(true);
    intervalRef.current = window.setInterval(() => {
      setTime((prev) => prev + 1);
    }, 1000);
  };

  const stop = () => {
    if (intervalRef.current !== null) {
      clearInterval(intervalRef.current);
      intervalRef.current = null;
    }
    setIsRunning(false);
  };

  const reset = () => {
    stop();
    setTime(0);
    setLaps([]);
  };

  const lap = () => {
    setLaps((prev) => [...prev, time]);
  };

  useEffect(() => {
    return () => {
      if (intervalRef.current !== null) {
        clearInterval(intervalRef.current);
      }
    };
  }, []);

  return (
    <div>
      <h2>{formatTime(time)}</h2>
      <button onClick={start} disabled={isRunning}>התחל</button>
      <button onClick={stop} disabled={!isRunning}>עצור</button>
      <button onClick={reset}>אפס</button>
      <button onClick={lap} disabled={!isRunning}>הקפה</button>

      {laps.length > 0 && (
        <div>
          <h3>הקפות:</h3>
          <ul>
            {laps.map((lapTime, index) => (
              <li key={index}>
                הקפה {index + 1}: {formatTime(lapTime)}
              </li>
            ))}
          </ul>
        </div>
      )}
    </div>
  );
}

הסבר:
- שמרנו את ה-interval ID ב-useRef כי שינוי שלו לא צריך לגרום לרנדר מחדש
- הפונקציה formatTime ממירה שניות לפורמט דקות:שניות עם padding של אפסים
- ב-cleanup של useEffect אנחנו מוודאים שה-interval מתנקה כשהקומפוננטה מתפרקת


פתרון תרגיל 2 - טופס עם פוקוס אוטומטי

import { useRef, useEffect, forwardRef, KeyboardEvent } from "react";

interface FormFieldProps {
  label: string;
  type: string;
  placeholder: string;
  onEnter?: () => void;
}

const FormField = forwardRef<HTMLInputElement, FormFieldProps>(
  ({ label, type, placeholder, onEnter }, ref) => {
    const handleKeyDown = (e: KeyboardEvent<HTMLInputElement>) => {
      if (e.key === "Enter" && onEnter) {
        e.preventDefault();
        onEnter();
      }
    };

    return (
      <div style={{ marginBottom: "10px" }}>
        <label>{label}</label>
        <input
          ref={ref}
          type={type}
          placeholder={placeholder}
          onKeyDown={handleKeyDown}
        />
      </div>
    );
  }
);

function RegistrationForm() {
  const nameRef = useRef<HTMLInputElement>(null);
  const emailRef = useRef<HTMLInputElement>(null);
  const passwordRef = useRef<HTMLInputElement>(null);

  useEffect(() => {
    nameRef.current?.focus();
  }, []);

  const handleSubmit = () => {
    const name = nameRef.current?.value;
    const email = emailRef.current?.value;
    const password = passwordRef.current?.value;

    if (!name || !email || !password) {
      alert("יש למלא את כל השדות");
      return;
    }

    console.log("נשלח:", { name, email, password });
    alert("הטופס נשלח בהצלחה!");
  };

  return (
    <form onSubmit={(e) => e.preventDefault()}>
      <h2>הרשמה</h2>
      <FormField
        ref={nameRef}
        label="שם מלא"
        type="text"
        placeholder="הכנס שם..."
        onEnter={() => emailRef.current?.focus()}
      />
      <FormField
        ref={emailRef}
        label="אימייל"
        type="email"
        placeholder="הכנס אימייל..."
        onEnter={() => passwordRef.current?.focus()}
      />
      <FormField
        ref={passwordRef}
        label="סיסמה"
        type="password"
        placeholder="הכנס סיסמה..."
        onEnter={handleSubmit}
      />
      <button onClick={handleSubmit}>הירשם</button>
    </form>
  );
}

הסבר:
- השתמשנו ב-forwardRef כדי להעביר ref לקומפוננטת FormField
- כל שדה מקבל פונקציית onEnter שמפנה את הפוקוס לשדה הבא
- ב-useEffect עם מערך תלויות ריק, אנחנו מתמקדים בשדה הראשון כשהקומפוננטה עולה


פתרון תרגיל 3 - רשימה מסוננת וממוינת עם useMemo

import { useState, useMemo } from "react";

interface Employee {
  id: number;
  name: string;
  department: string;
  salary: number;
  startDate: string;
}

const employees: Employee[] = [
  { id: 1, name: "דני", department: "פיתוח", salary: 25000, startDate: "2020-03-15" },
  { id: 2, name: "מיכל", department: "עיצוב", salary: 22000, startDate: "2021-07-01" },
  { id: 3, name: "יוסי", department: "פיתוח", salary: 28000, startDate: "2019-01-10" },
  { id: 4, name: "שרה", department: "שיווק", salary: 20000, startDate: "2022-05-20" },
  { id: 5, name: "אבי", department: "פיתוח", salary: 30000, startDate: "2018-11-03" },
  { id: 6, name: "רונית", department: "עיצוב", salary: 24000, startDate: "2020-09-12" },
  { id: 7, name: "גל", department: "שיווק", salary: 21000, startDate: "2023-01-15" },
  { id: 8, name: "נועה", department: "פיתוח", salary: 27000, startDate: "2021-03-28" },
];

type SortField = "name" | "salary" | "startDate";

function EmployeeList() {
  const [search, setSearch] = useState("");
  const [department, setDepartment] = useState("all");
  const [sortBy, setSortBy] = useState<SortField>("name");
  const [sortOrder, setSortOrder] = useState<"asc" | "desc">("asc");

  const departments = useMemo(() => {
    const depts = new Set(employees.map((e) => e.department));
    return Array.from(depts);
  }, []);

  const filteredAndSorted = useMemo(() => {
    let result = employees.filter((emp) => {
      const matchesSearch = emp.name.includes(search);
      const matchesDept = department === "all" || emp.department === department;
      return matchesSearch && matchesDept;
    });

    result.sort((a, b) => {
      let comparison = 0;
      switch (sortBy) {
        case "name":
          comparison = a.name.localeCompare(b.name);
          break;
        case "salary":
          comparison = a.salary - b.salary;
          break;
        case "startDate":
          comparison =
            new Date(a.startDate).getTime() - new Date(b.startDate).getTime();
          break;
      }
      return sortOrder === "asc" ? comparison : -comparison;
    });

    return result;
  }, [search, department, sortBy, sortOrder]);

  const stats = useMemo(() => {
    if (filteredAndSorted.length === 0) {
      return { avg: 0, max: 0, min: 0 };
    }
    const salaries = filteredAndSorted.map((e) => e.salary);
    const sum = salaries.reduce((a, b) => a + b, 0);
    return {
      avg: Math.round(sum / salaries.length),
      max: Math.max(...salaries),
      min: Math.min(...salaries),
    };
  }, [filteredAndSorted]);

  return (
    <div>
      <h2>רשימת עובדים</h2>

      <div>
        <input
          value={search}
          onChange={(e) => setSearch(e.target.value)}
          placeholder="חפש לפי שם..."
        />
        <select value={department} onChange={(e) => setDepartment(e.target.value)}>
          <option value="all">כל המחלקות</option>
          {departments.map((dept) => (
            <option key={dept} value={dept}>{dept}</option>
          ))}
        </select>
        <select value={sortBy} onChange={(e) => setSortBy(e.target.value as SortField)}>
          <option value="name">מיין לפי שם</option>
          <option value="salary">מיין לפי שכר</option>
          <option value="startDate">מיין לפי תאריך</option>
        </select>
        <button onClick={() => setSortOrder(sortOrder === "asc" ? "desc" : "asc")}>
          {sortOrder === "asc" ? "סדר עולה" : "סדר יורד"}
        </button>
      </div>

      <div>
        <p>ממוצע שכר: {stats.avg.toLocaleString()} ש"ח</p>
        <p>שכר מקסימלי: {stats.max.toLocaleString()} ש"ח</p>
        <p>שכר מינימלי: {stats.min.toLocaleString()} ש"ח</p>
      </div>

      <table>
        <thead>
          <tr>
            <th>שם</th>
            <th>מחלקה</th>
            <th>שכר</th>
            <th>תאריך תחילה</th>
          </tr>
        </thead>
        <tbody>
          {filteredAndSorted.map((emp) => (
            <tr key={emp.id}>
              <td>{emp.name}</td>
              <td>{emp.department}</td>
              <td>{emp.salary.toLocaleString()} ש"ח</td>
              <td>{new Date(emp.startDate).toLocaleDateString("he-IL")}</td>
            </tr>
          ))}
        </tbody>
      </table>

      <p>מציג {filteredAndSorted.length} מתוך {employees.length} עובדים</p>
    </div>
  );
}

הסבר:
- השתמשנו ב-useMemo שלוש פעמים: לרשימת המחלקות, לרשימה המסוננת וממוינת, ולסטטיסטיקות
- ה-stats תלוי ב-filteredAndSorted, כך שהוא יחושב מחדש רק כשהרשימה המסוננת משתנה
- שימו לב שרשימת המחלקות מחושבת פעם אחת בלבד (מערך תלויות ריק) כי employees לא משתנה


פתרון תרגיל 4 - רשימת משימות עם useCallback

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

interface Todo {
  id: number;
  text: string;
  completed: boolean;
}

interface TodoItemProps {
  todo: Todo;
  onToggle: (id: number) => void;
  onDelete: (id: number) => void;
  onEdit: (id: number, newText: string) => void;
}

const TodoItem = memo(({ todo, onToggle, onDelete, onEdit }: TodoItemProps) => {
  console.log(`Rendering: ${todo.text}`);
  const [isEditing, setIsEditing] = useState(false);
  const [editText, setEditText] = useState(todo.text);

  const handleSave = () => {
    if (editText.trim()) {
      onEdit(todo.id, editText);
      setIsEditing(false);
    }
  };

  return (
    <li>
      {isEditing ? (
        <div>
          <input
            value={editText}
            onChange={(e) => setEditText(e.target.value)}
            onKeyDown={(e) => e.key === "Enter" && handleSave()}
          />
          <button onClick={handleSave}>שמור</button>
          <button onClick={() => setIsEditing(false)}>בטל</button>
        </div>
      ) : (
        <div>
          <span
            style={{
              textDecoration: todo.completed ? "line-through" : "none",
              cursor: "pointer",
            }}
            onClick={() => onToggle(todo.id)}
          >
            {todo.text}
          </span>
          <button onClick={() => setIsEditing(true)}>ערוך</button>
          <button onClick={() => onDelete(todo.id)}>מחק</button>
        </div>
      )}
    </li>
  );
});

function TodoApp() {
  const [todos, setTodos] = useState<Todo[]>([]);
  const [input, setInput] = useState("");
  const nextIdRef = useRef(1);
  const renderCountRef = useRef(0);
  renderCountRef.current++;

  const addTodo = () => {
    if (!input.trim()) return;
    setTodos((prev) => [
      ...prev,
      { id: nextIdRef.current++, text: input.trim(), completed: false },
    ]);
    setInput("");
  };

  const toggleTodo = useCallback((id: number) => {
    setTodos((prev) =>
      prev.map((todo) =>
        todo.id === id ? { ...todo, completed: !todo.completed } : todo
      )
    );
  }, []);

  const deleteTodo = useCallback((id: number) => {
    setTodos((prev) => prev.filter((todo) => todo.id !== id));
  }, []);

  const editTodo = useCallback((id: number, newText: string) => {
    setTodos((prev) =>
      prev.map((todo) => (todo.id === id ? { ...todo, text: newText } : todo))
    );
  }, []);

  return (
    <div>
      <h2>רשימת משימות</h2>
      <p>מספר רנדרים של קומפוננטה ראשית: {renderCountRef.current}</p>
      <div>
        <input
          value={input}
          onChange={(e) => setInput(e.target.value)}
          onKeyDown={(e) => e.key === "Enter" && addTodo()}
          placeholder="הוסף משימה..."
        />
        <button onClick={addTodo}>הוסף</button>
      </div>
      <ul>
        {todos.map((todo) => (
          <TodoItem
            key={todo.id}
            todo={todo}
            onToggle={toggleTodo}
            onDelete={deleteTodo}
            onEdit={editTodo}
          />
        ))}
      </ul>
    </div>
  );
}

הסבר:
- כל פונקציית handler עטופה ב-useCallback עם מערך תלויות ריק
- השתמשנו בצורה הפונקציונלית של setState (כמו prev => ...) כדי להימנע מתלות ב-state
- מונה הרנדרים משתמש ב-useRef כדי לא לגרום לרנדר נוסף בעדכון
- הקומפוננטה TodoItem עטופה ב-memo, כך שהיא תרנדר מחדש רק כש-props שלה משתנים


פתרון תרגיל 5 - חיפוש עם Debounce

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

const allItems = [
  "ריאקט", "אנגולר", "ויו", "סבלט", "נקסט",
  "טייפסקריפט", "ג'אווהסקריפט", "פייתון", "ג'אווה", "סי שארפ",
  "נוד", "דנו", "באן", "ראסט", "גו",
];

function DebouncedSearch() {
  const [inputValue, setInputValue] = useState("");
  const [searchTerm, setSearchTerm] = useState("");
  const [isLoading, setIsLoading] = useState(false);
  const timeoutRef = useRef<number | null>(null);

  const debouncedSearch = useCallback((term: string) => {
    if (timeoutRef.current !== null) {
      clearTimeout(timeoutRef.current);
    }

    if (term.trim() === "") {
      setSearchTerm("");
      setIsLoading(false);
      return;
    }

    setIsLoading(true);

    timeoutRef.current = window.setTimeout(() => {
      setSearchTerm(term);
      setIsLoading(false);
      timeoutRef.current = null;
    }, 500);
  }, []);

  const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
    const value = e.target.value;
    setInputValue(value);
    debouncedSearch(value);
  };

  const results = useMemo(() => {
    if (!searchTerm) return [];
    return allItems.filter((item) => item.includes(searchTerm));
  }, [searchTerm]);

  useEffect(() => {
    return () => {
      if (timeoutRef.current !== null) {
        clearTimeout(timeoutRef.current);
      }
    };
  }, []);

  return (
    <div>
      <h2>חיפוש עם Debounce</h2>
      <input
        value={inputValue}
        onChange={handleChange}
        placeholder="חפש טכנולוגיה..."
      />
      {isLoading && <p>מחפש...</p>}
      {!isLoading && searchTerm && (
        <div>
          <p>תוצאות עבור "{searchTerm}":</p>
          {results.length > 0 ? (
            <ul>
              {results.map((item, index) => (
                <li key={index}>{item}</li>
              ))}
            </ul>
          ) : (
            <p>לא נמצאו תוצאות</p>
          )}
        </div>
      )}
    </div>
  );
}

הסבר:
- שמרנו את ה-timeout ID ב-useRef כדי שנוכל לבטל timeout קודם כשהמשתמש ממשיך להקליד
- הפרדנו בין inputValue (מה שהמשתמש רואה) ל-searchTerm (מה שמשמש לסינון)
- הפונקציה debouncedSearch עטופה ב-useCallback כי היא לא תלויה בשום state
- הסינון בפועל נעשה עם useMemo שתלוי ב-searchTerm


פתרון תרגיל 6 - גלריית תמונות עם Intersection Observer

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

interface LazyImageProps {
  src: string;
  alt: string;
  index: number;
  onVisible: (index: number) => void;
}

const LazyImage = memo(({ src, alt, index, onVisible }: LazyImageProps) => {
  const imgRef = useRef<HTMLDivElement>(null);
  const [isVisible, setIsVisible] = useState(false);
  const [isLoaded, setIsLoaded] = useState(false);

  useEffect(() => {
    const observer = new IntersectionObserver(
      ([entry]) => {
        if (entry.isIntersecting) {
          setIsVisible(true);
          onVisible(index);
          observer.disconnect();
        }
      },
      { threshold: 0.1 }
    );

    if (imgRef.current) {
      observer.observe(imgRef.current);
    }

    return () => observer.disconnect();
  }, [index, onVisible]);

  return (
    <div
      ref={imgRef}
      style={{
        width: "300px",
        height: "200px",
        margin: "10px",
        backgroundColor: "#f0f0f0",
        overflow: "hidden",
      }}
    >
      {isVisible ? (
        <img
          src={src}
          alt={alt}
          onLoad={() => setIsLoaded(true)}
          style={{
            width: "100%",
            height: "100%",
            objectFit: "cover",
            opacity: isLoaded ? 1 : 0,
            transition: "opacity 0.5s ease-in",
          }}
        />
      ) : (
        <div
          style={{
            width: "100%",
            height: "100%",
            display: "flex",
            alignItems: "center",
            justifyContent: "center",
            color: "#999",
          }}
        >
          טוען...
        </div>
      )}
    </div>
  );
});

function ImageGallery() {
  const totalImages = 20;
  const [loadedCount, setLoadedCount] = useState(0);
  const loadedSet = useRef(new Set<number>());

  const images = Array.from({ length: totalImages }, (_, i) => ({
    src: `https://picsum.photos/300/200?random=${i}`,
    alt: `תמונה ${i + 1}`,
  }));

  const handleVisible = useCallback((index: number) => {
    if (!loadedSet.current.has(index)) {
      loadedSet.current.add(index);
      setLoadedCount(loadedSet.current.size);
    }
  }, []);

  return (
    <div>
      <h2>גלריית תמונות</h2>
      <p>
        נטענו {loadedCount} מתוך {totalImages} תמונות
      </p>
      <div
        style={{
          display: "flex",
          flexWrap: "wrap",
          justifyContent: "center",
        }}
      >
        {images.map((image, index) => (
          <LazyImage
            key={index}
            src={image.src}
            alt={image.alt}
            index={index}
            onVisible={handleVisible}
          />
        ))}
      </div>
    </div>
  );
}

הסבר:
- כל תמונה עטופה בקומפוננטת LazyImage שמשתמשת ב-Intersection Observer
- כשהתמונה נכנסת לתצוגה, ה-observer מודיע ואז אנחנו טוענים את התמונה בפועל
- אנימציית fade-in מתבצעת על ידי שינוי opacity מ-0 ל-1 עם transition
- השתמשנו ב-Set ב-useRef כדי לעקוב אחרי תמונות שנטענו ללא כפילויות
- הפונקציה handleVisible עטופה ב-useCallback כדי שה-memo על LazyImage יעבוד נכון


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

  1. הבדל בין useState ל-useRef: שינוי ב-useState גורם לרנדר מחדש של הקומפוננטה, בעוד ששינוי ב-useRef לא גורם לרנדר. נעדיף useState כשהערך צריך להופיע ב-UI (כי שינוי שלו צריך לעדכן את המסך), ונעדיף useRef כשהערך נדרש רק "מאחורי הקלעים" (כמו timer IDs, ערכים קודמים, או מונים פנימיים).

  2. useMemo לא מבטיח שמירה לנצח: ריאקט עשויה "לשכוח" ערכים ששמורים ב-useMemo ולחשב אותם מחדש, גם אם התלויות לא השתנו. זה יכול לקרות כשריאקט צריכה לפנות זיכרון. לכן, useMemo הוא אופטימיזציית ביצועים ולא מנגנון שמירת מצב - הקוד חייב לעבוד נכון גם בלי ה-memoization.

  3. useCallback ללא memo: אם קומפוננטת הילד לא עטופה ב-React.memo, היא תרנדר מחדש בכל רנדר של ההורה בלי קשר ל-props. במקרה כזה, useCallback רק מוסיף overhead מיותר (שמירת הפונקציה, השוואת תלויות) בלי שום תועלת. useCallback מועיל רק כשהקומפוננטה שמקבלת את הפונקציה בודקת אם ה-props באמת השתנו (דרך memo או shouldComponentUpdate).

  4. הקשר בין useCallback ל-useMemo: useCallback(fn, deps) שקול ל-useMemo(() => fn, deps). שניהם שומרים ערך בין רנדרים, אבל useCallback שומר את הפונקציה עצמה, ו-useMemo שומר את התוצאה של הפונקציה. אפשר בהחלט לממש useCallback באמצעות useMemo, אבל useCallback הוא פשוט יותר לקריאה כשמדובר בפונקציות.

  5. סיכוני שימוש יתר: שימוש יתר ב-useMemo ו-useCallback מוסיף מורכבות לקוד, צורך זיכרון נוסף (לשמירת הערכים הקודמים), ומוסיף overhead של השוואת תלויות בכל רנדר. אם החישוב המקורי זול, ה-overhead של ה-memoization עלול להיות גדול מהחיסכון. כמו כן, תלויות שגויות יכולות לגרום לבאגים קשים לאיתור.