לדלג לתוכן

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 בכל שינוי.

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

  1. בלי מערך תלויות, ה-effect רץ אחרי כל רינדור (mount ו-re-renders). עם מערך ריק [], ה-effect רץ רק פעם אחת אחרי ה-mount הראשון, וה-cleanup רץ רק ב-unmount. מערך ריק מתאים לדברים שצריכים לקרות פעם אחת - הרשמה לאירועים, שליפת נתונים ראשונית.

  2. useEffect מצפה שהפונקציה תחזיר void או פונקציית cleanup. פונקציה async מחזירה Promise, שריאקט לא יודעת מה לעשות איתו. הפתרון הוא ליצור פונקציה async בתוך ה-effect ולקרוא לה מיד: useEffect(() => { const fetchData = async () => {...}; fetchData(); }, []).

  3. בלי cleanup ל-setInterval, ה-interval ממשיך לרוץ גם אחרי שהקומפוננטה נמחקה מה-DOM. זה גורם ל-memory leak ולשגיאות ("can't set state on unmounted component"). בנוסף, כל פעם שה-effect רץ מחדש, נוצר interval נוסף - ואז יש כמה intervals שרצים במקביל.

  4. בלי AbortController, אם userId משתנה מ-1 ל-2 ל-3 מהר, שלוש בקשות נשלחות במקביל. אם הבקשה עבור userId=2 חוזרת אחרי userId=3, התוצאה הישנה דורסת את החדשה. AbortController מבטל את הבקשות הקודמות כך שרק התוצאה של הבקשה האחרונה מתקבלת.

  5. "stale closure" קורה כשפונקציה בתוך useEffect "לוכדת" את הערך של סטייט מרינדור ספציפי. למשל, אם count הוא 0 כשה-effect נוצר, setCount(count + 1) תמיד מחשב 0 + 1 = 1, לא משנה כמה פעמים זה נקרא. עדכון פונקציונלי setCount(prev => prev + 1) פותר את זה כי הוא תמיד מקבל את הערך העדכני ביותר כפרמטר.