6.8 useEffect פתרון
פתרון - useEffect¶
פתרון תרגיל 1¶
import { useState, useEffect } from "react";
function DynamicTitle() {
const [text, setText] = useState("");
useEffect(() => {
document.title = text || "React App";
return () => {
document.title = "React App";
};
}, [text]);
return (
<div>
<input
type="text"
value={text}
onChange={(e) => setText(e.target.value)}
placeholder="Enter page title..."
/>
<p>Current title: {text || "React App"}</p>
</div>
);
}
ה-effect רץ כל פעם ש-text משתנה. ה-cleanup מחזיר את הכותרת כשהקומפוננטה נמחקת.
פתרון תרגיל 2¶
import { useState, useEffect } from "react";
function DigitalClock() {
const [time, setTime] = useState(new Date());
const [isRunning, setIsRunning] = useState(true);
useEffect(() => {
if (!isRunning) return;
const interval = setInterval(() => {
setTime(new Date());
}, 1000);
return () => clearInterval(interval);
}, [isRunning]);
const formatTime = (date: Date): string => {
return date.toLocaleTimeString("en-US", {
hour12: false,
hour: "2-digit",
minute: "2-digit",
second: "2-digit",
});
};
return (
<div style={{ textAlign: "center" }}>
<h1 style={{ fontFamily: "monospace", fontSize: "48px" }}>
{formatTime(time)}
</h1>
<button onClick={() => setIsRunning(!isRunning)}>
{isRunning ? "Stop" : "Start"}
</button>
</div>
);
}
כש-isRunning הוא false, ה-effect חוזר מוקדם ולא יוצר interval. כש-isRunning משתנה ל-true, ה-effect רץ מחדש ויוצר interval חדש.
פתרון תרגיל 3¶
import { useState, useEffect } from "react";
interface User {
id: number;
name: string;
email: string;
phone: string;
company: {
name: string;
};
}
function UserProfile({ userId }: { userId: number }) {
const [user, setUser] = useState<User | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
const controller = new AbortController();
const fetchUser = async () => {
setLoading(true);
setError(null);
try {
const res = await fetch(
`https://jsonplaceholder.typicode.com/users/${userId}`,
{ signal: controller.signal }
);
if (!res.ok) throw new Error("User not found");
const data: User = await res.json();
setUser(data);
} catch (err) {
if (err instanceof Error && err.name !== "AbortError") {
setError(err.message);
}
} finally {
setLoading(false);
}
};
fetchUser();
return () => controller.abort();
}, [userId]);
if (loading) return <p>Loading...</p>;
if (error) return <p style={{ color: "red" }}>Error: {error}</p>;
if (!user) return null;
return (
<div style={{ border: "1px solid #ddd", padding: "16px", borderRadius: "8px" }}>
<h2>{user.name}</h2>
<p>Email: {user.email}</p>
<p>Phone: {user.phone}</p>
<p>Company: {user.company.name}</p>
</div>
);
}
function App() {
const [userId, setUserId] = useState(1);
return (
<div>
<div style={{ marginBottom: "16px" }}>
{[1, 2, 3, 4, 5].map((id) => (
<button
key={id}
onClick={() => setUserId(id)}
style={{ fontWeight: userId === id ? "bold" : "normal", margin: "0 4px" }}
>
User {id}
</button>
))}
</div>
<UserProfile userId={userId} />
</div>
);
}
ה-AbortController מבטל בקשות ישנות כש-userId משתנה מהר. שימו לב שמסננים את AbortError ב-catch כי זו לא שגיאה אמיתית.
פתרון תרגיל 4¶
import { useState, useEffect } from "react";
function Notes() {
const [note, setNote] = useState(() => {
return localStorage.getItem("note") ?? "";
});
const [saved, setSaved] = useState(false);
useEffect(() => {
localStorage.setItem("note", note);
setSaved(true);
const timeout = setTimeout(() => {
setSaved(false);
}, 2000);
return () => clearTimeout(timeout);
}, [note]);
const handleClear = () => {
setNote("");
localStorage.removeItem("note");
};
return (
<div>
<textarea
value={note}
onChange={(e) => setNote(e.target.value)}
rows={6}
style={{ width: "100%" }}
placeholder="Write your note..."
/>
<div style={{ display: "flex", justifyContent: "space-between", marginTop: "8px" }}>
<button onClick={handleClear}>Clear</button>
{saved && <span style={{ color: "green" }}>Auto-saved</span>}
</div>
</div>
);
}
הערך ההתחלתי נקרא מ-localStorage עם lazy initializer (פונקציה ב-useState). הודעת "נשמר אוטומטית" נעלמת אחרי 2 שניות עם timeout.
פתרון תרגיל 5¶
import { useState, useEffect } from "react";
interface User {
id: number;
name: string;
email: string;
}
function DebouncedSearch() {
const [search, setSearch] = useState("");
const [users, setUsers] = useState<User[]>([]);
const [loading, setLoading] = useState(false);
useEffect(() => {
if (!search.trim()) {
setUsers([]);
return;
}
setLoading(true);
const timeout = setTimeout(() => {
fetch("https://jsonplaceholder.typicode.com/users")
.then((res) => res.json())
.then((data: User[]) => {
const filtered = data.filter((u) =>
u.name.toLowerCase().includes(search.toLowerCase())
);
setUsers(filtered);
setLoading(false);
})
.catch(() => {
setLoading(false);
});
}, 500);
return () => clearTimeout(timeout);
}, [search]);
return (
<div>
<input
type="text"
value={search}
onChange={(e) => setSearch(e.target.value)}
placeholder="Search users..."
style={{ padding: "8px", width: "100%", marginBottom: "16px" }}
/>
{loading && <p>Searching...</p>}
{!loading && search && users.length === 0 && <p>No users found</p>}
<ul>
{users.map((user) => (
<li key={user.id}>
{user.name} - {user.email}
</li>
))}
</ul>
</div>
);
}
ה-debounce עובד כך: בכל שינוי ב-search, ה-cleanup מבטל את ה-timeout הקודם. רק אם המשתמש לא הקליד 500ms, ה-timeout רץ ושולח את הבקשה.
פתרון תרגיל 6¶
import { useState, useEffect } from "react";
type ScreenSize = "mobile" | "tablet" | "desktop";
function getScreenSize(width: number): ScreenSize {
if (width < 768) return "mobile";
if (width <= 1024) return "tablet";
return "desktop";
}
function ResponsiveInfo() {
const [width, setWidth] = useState(window.innerWidth);
const [height, setHeight] = useState(window.innerHeight);
const [resizeCount, setResizeCount] = useState(0);
useEffect(() => {
const handleResize = () => {
setWidth(window.innerWidth);
setHeight(window.innerHeight);
setResizeCount((prev) => prev + 1);
};
window.addEventListener("resize", handleResize);
return () => {
window.removeEventListener("resize", handleResize);
};
}, []);
const screenSize = getScreenSize(width);
return (
<div style={{ padding: "16px" }}>
<h2>Window Info</h2>
<p>Width: {width}px</p>
<p>Height: {height}px</p>
<p>
Screen size:{" "}
<strong style={{
color: screenSize === "mobile" ? "red" : screenSize === "tablet" ? "orange" : "green"
}}>
{screenSize}
</strong>
</p>
<p>Resize count: {resizeCount}</p>
</div>
);
}
שימו לב: setResizeCount((prev) => prev + 1) משתמש בעדכון פונקציונלי. אם היינו כותבים setResizeCount(resizeCount + 1), היינו צריכים להוסיף את resizeCount למערך התלויות, מה שהיה גורם לרישום מחדש של ה-listener בכל שינוי.
תשובות לשאלות¶
-
בלי מערך תלויות, ה-effect רץ אחרי כל רינדור (mount ו-re-renders). עם מערך ריק
[], ה-effect רץ רק פעם אחת אחרי ה-mount הראשון, וה-cleanup רץ רק ב-unmount. מערך ריק מתאים לדברים שצריכים לקרות פעם אחת - הרשמה לאירועים, שליפת נתונים ראשונית. -
useEffectמצפה שהפונקציה תחזירvoidאו פונקציית cleanup. פונקציה async מחזירהPromise, שריאקט לא יודעת מה לעשות איתו. הפתרון הוא ליצור פונקציה async בתוך ה-effect ולקרוא לה מיד:useEffect(() => { const fetchData = async () => {...}; fetchData(); }, []). -
בלי cleanup ל-setInterval, ה-interval ממשיך לרוץ גם אחרי שהקומפוננטה נמחקה מה-DOM. זה גורם ל-memory leak ולשגיאות ("can't set state on unmounted component"). בנוסף, כל פעם שה-effect רץ מחדש, נוצר interval נוסף - ואז יש כמה intervals שרצים במקביל.
-
בלי AbortController, אם userId משתנה מ-1 ל-2 ל-3 מהר, שלוש בקשות נשלחות במקביל. אם הבקשה עבור userId=2 חוזרת אחרי userId=3, התוצאה הישנה דורסת את החדשה. AbortController מבטל את הבקשות הקודמות כך שרק התוצאה של הבקשה האחרונה מתקבלת.
-
"stale closure" קורה כשפונקציה בתוך useEffect "לוכדת" את הערך של סטייט מרינדור ספציפי. למשל, אם
countהוא 0 כשה-effect נוצר,setCount(count + 1)תמיד מחשב0 + 1 = 1, לא משנה כמה פעמים זה נקרא. עדכון פונקציונליsetCount(prev => prev + 1)פותר את זה כי הוא תמיד מקבל את הערך העדכני ביותר כפרמטר.