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 יעבוד נכון
תשובות לשאלות¶
-
הבדל בין useState ל-useRef: שינוי ב-useState גורם לרנדר מחדש של הקומפוננטה, בעוד ששינוי ב-useRef לא גורם לרנדר. נעדיף useState כשהערך צריך להופיע ב-UI (כי שינוי שלו צריך לעדכן את המסך), ונעדיף useRef כשהערך נדרש רק "מאחורי הקלעים" (כמו timer IDs, ערכים קודמים, או מונים פנימיים).
-
useMemo לא מבטיח שמירה לנצח: ריאקט עשויה "לשכוח" ערכים ששמורים ב-useMemo ולחשב אותם מחדש, גם אם התלויות לא השתנו. זה יכול לקרות כשריאקט צריכה לפנות זיכרון. לכן, useMemo הוא אופטימיזציית ביצועים ולא מנגנון שמירת מצב - הקוד חייב לעבוד נכון גם בלי ה-memoization.
-
useCallback ללא memo: אם קומפוננטת הילד לא עטופה ב-React.memo, היא תרנדר מחדש בכל רנדר של ההורה בלי קשר ל-props. במקרה כזה, useCallback רק מוסיף overhead מיותר (שמירת הפונקציה, השוואת תלויות) בלי שום תועלת. useCallback מועיל רק כשהקומפוננטה שמקבלת את הפונקציה בודקת אם ה-props באמת השתנו (דרך memo או shouldComponentUpdate).
-
הקשר בין useCallback ל-useMemo:
useCallback(fn, deps)שקול ל-useMemo(() => fn, deps). שניהם שומרים ערך בין רנדרים, אבל useCallback שומר את הפונקציה עצמה, ו-useMemo שומר את התוצאה של הפונקציה. אפשר בהחלט לממש useCallback באמצעות useMemo, אבל useCallback הוא פשוט יותר לקריאה כשמדובר בפונקציות. -
סיכוני שימוש יתר: שימוש יתר ב-useMemo ו-useCallback מוסיף מורכבות לקוד, צורך זיכרון נוסף (לשמירת הערכים הקודמים), ומוסיף overhead של השוואת תלויות בכל רנדר. אם החישוב המקורי זול, ה-overhead של ה-memoization עלול להיות גדול מהחיסכון. כמו כן, תלויות שגויות יכולות לגרום לבאגים קשים לאיתור.