לדלג לתוכן

6.8 useEffect הרצאה

תופעות לוואי - side effects

קומפוננטת ריאקט היא פונקציה שמקבלת props ומחזירה JSX. אבל לפעמים צריך לעשות דברים שהם מעבר לרינדור - לגשת לשרת, להאזין לאירועים, לעדכן את הכותרת, להפעיל טיימר. אלה נקראים תופעות לוואי (side effects).

דוגמאות לתופעות לוואי:

  • שליפת נתונים מ-API (fetch)
  • הרשמה לאירועים (addEventListener)
  • שינוי כותרת הדף (document.title)
  • טיימרים (setTimeout, setInterval)
  • אינטגרציה עם ספריות חיצוניות

useEffect - הוק לתופעות לוואי

useEffect מאפשר להריץ קוד אחרי שריאקט סיימה לרנדר את הקומפוננטה:

import { useEffect, useState } from "react";

function PageTitle() {
    const [count, setCount] = useState(0);

    useEffect(() => {
        document.title = `Count: ${count}`;
    });

    return (
        <div>
            <p>{count}</p>
            <button onClick={() => setCount(count + 1)}>+1</button>
        </div>
    );
}

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

מערך תלויות - dependency array

בלי מערך תלויות, ה-effect רץ אחרי כל רינדור. אפשר לשלוט מתי הוא רץ עם הפרמטר השני:

ריצה בכל רינדור

useEffect(() => {
    console.log("Runs after every render");
});

ריצה רק בטעינה ראשונה

מערך תלויות ריק - ה-effect רץ רק פעם אחת, כשהקומפוננטה עולה (mount):

useEffect(() => {
    console.log("Runs only once, on mount");
}, []);

ריצה כשערך ספציפי משתנה

מערך תלויות עם ערכים - ה-effect רץ רק כשאחד מהערכים משתנה:

useEffect(() => {
    console.log("count changed to:", count);
}, [count]);
useEffect(() => {
    console.log("name or age changed");
}, [name, age]);

הכלל: כל משתנה שה-effect משתמש בו ומגיע מהקומפוננטה (סטייט, props, משתנים מחושבים) חייב להיות במערך התלויות.

פונקציית ניקוי - cleanup function

effect יכול להחזיר פונקציה שרצה לפני ה-effect הבא, ולפני שהקומפוננטה נמחקת (unmount):

useEffect(() => {
    // setup
    const timer = setInterval(() => {
        console.log("tick");
    }, 1000);

    // cleanup - runs before next effect and on unmount
    return () => {
        clearInterval(timer);
    };
}, []);

למה צריך cleanup

בלי cleanup, משאבים יכולים לדלוף:

  • טיימרים ממשיכים לרוץ אחרי שהקומפוננטה נמחקה
  • event listeners נשארים רשומים
  • בקשות HTTP חוזרות גם כשהתוצאה כבר לא רלוונטית
function WindowSize() {
    const [width, setWidth] = useState(window.innerWidth);

    useEffect(() => {
        const handleResize = () => {
            setWidth(window.innerWidth);
        };

        window.addEventListener("resize", handleResize);

        return () => {
            window.removeEventListener("resize", handleResize);
        };
    }, []);

    return <p>Window width: {width}px</p>;
}

מערך תלויות ריק ([]) כי רוצים להירשם לאירוע פעם אחת בלבד. ה-cleanup מסיר את ה-listener כשהקומפוננטה נמחקת.

הזמנים של effect ו-cleanup

Mount:     effect runs
Re-render: cleanup runs -> effect runs
Re-render: cleanup runs -> effect runs
Unmount:   cleanup runs

שליפת נתונים - fetching data

אחד השימושים הנפוצים ביותר של useEffect הוא שליפת נתונים מ-API:

interface User {
    id: number;
    name: string;
    email: string;
}

function UserList() {
    const [users, setUsers] = useState<User[]>([]);
    const [loading, setLoading] = useState(true);
    const [error, setError] = useState<string | null>(null);

    useEffect(() => {
        fetch("https://jsonplaceholder.typicode.com/users")
            .then((res) => {
                if (!res.ok) throw new Error("Failed to fetch");
                return res.json();
            })
            .then((data: User[]) => {
                setUsers(data);
                setLoading(false);
            })
            .catch((err) => {
                setError(err.message);
                setLoading(false);
            });
    }, []);

    if (loading) return <p>Loading...</p>;
    if (error) return <p>Error: {error}</p>;

    return (
        <ul>
            {users.map((user) => (
                <li key={user.id}>{user.name}</li>
            ))}
        </ul>
    );
}

שליפה עם async/await

useEffect לא יכול להיות async ישירות. צריך ליצור פונקציה async בתוכו:

useEffect(() => {
    const fetchUsers = async () => {
        try {
            const res = await fetch("https://jsonplaceholder.typicode.com/users");
            if (!res.ok) throw new Error("Failed to fetch");
            const data: User[] = await res.json();
            setUsers(data);
        } catch (err) {
            setError(err instanceof Error ? err.message : "Unknown error");
        } finally {
            setLoading(false);
        }
    };

    fetchUsers();
}, []);

למה לא useEffect(async () => ...)? כי פונקציה async מחזירה Promise, אבל useEffect מצפה שהפונקציה תחזיר void או פונקציית cleanup.

שליפה שתלויה בפרמטר

כשהנתונים תלויים בערך שמשתנה (למשל id של משתמש), מוסיפים אותו למערך התלויות:

interface Post {
    id: number;
    title: string;
    body: string;
}

function UserPosts({ userId }: { userId: number }) {
    const [posts, setPosts] = useState<Post[]>([]);
    const [loading, setLoading] = useState(true);

    useEffect(() => {
        setLoading(true);
        fetch(`https://jsonplaceholder.typicode.com/posts?userId=${userId}`)
            .then((res) => res.json())
            .then((data: Post[]) => {
                setPosts(data);
                setLoading(false);
            });
    }, [userId]); // re-fetch when userId changes

    if (loading) return <p>Loading posts...</p>;

    return (
        <ul>
            {posts.map((post) => (
                <li key={post.id}>{post.title}</li>
            ))}
        </ul>
    );
}

כל פעם ש-userId משתנה, ה-effect רץ מחדש ושולף את הפוסטים של המשתמש החדש.

ביטול בקשות עם AbortController

כשהמשתמש משנה את הערך מהר, בקשות ישנות עדיין בדרך ויכולות לדרוס תוצאות חדשות. AbortController פותר את זה:

useEffect(() => {
    const controller = new AbortController();

    const fetchPosts = async () => {
        setLoading(true);
        try {
            const res = await fetch(
                `https://jsonplaceholder.typicode.com/posts?userId=${userId}`,
                { signal: controller.signal }
            );
            const data: Post[] = await res.json();
            setPosts(data);
        } catch (err) {
            if (err instanceof Error && err.name !== "AbortError") {
                setError(err.message);
            }
        } finally {
            setLoading(false);
        }
    };

    fetchPosts();

    return () => {
        controller.abort(); // cancel previous request
    };
}, [userId]);

ה-cleanup מבטל את הבקשה הקודמת כשה-effect רץ מחדש.

דפוסים נפוצים

טיימר עם cleanup

function Timer() {
    const [seconds, setSeconds] = useState(0);
    const [isRunning, setIsRunning] = useState(false);

    useEffect(() => {
        if (!isRunning) return;

        const interval = setInterval(() => {
            setSeconds((prev) => prev + 1);
        }, 1000);

        return () => clearInterval(interval);
    }, [isRunning]);

    return (
        <div>
            <p>Time: {seconds}s</p>
            <button onClick={() => setIsRunning(!isRunning)}>
                {isRunning ? "Stop" : "Start"}
            </button>
            <button onClick={() => { setIsRunning(false); setSeconds(0); }}>
                Reset
            </button>
        </div>
    );
}

שמירה ב-localStorage

function PersistentCounter() {
    const [count, setCount] = useState(() => {
        const saved = localStorage.getItem("count");
        return saved ? Number(saved) : 0;
    });

    useEffect(() => {
        localStorage.setItem("count", String(count));
    }, [count]);

    return (
        <div>
            <p>Count: {count}</p>
            <button onClick={() => setCount(count + 1)}>+1</button>
        </div>
    );
}

הערך ההתחלתי נקרא מ-localStorage (עם lazy initializer). בכל פעם ש-count משתנה, הוא נשמר ב-localStorage.

האזנה ללחיצת מקשים

function KeyListener() {
    const [lastKey, setLastKey] = useState("");

    useEffect(() => {
        const handleKeyDown = (e: KeyboardEvent) => {
            setLastKey(e.key);
        };

        document.addEventListener("keydown", handleKeyDown);

        return () => {
            document.removeEventListener("keydown", handleKeyDown);
        };
    }, []);

    return <p>Last key pressed: {lastKey || "None"}</p>;
}

טעויות נפוצות

שכחה של תלויות

// BAD - count is always 0 inside the effect
useEffect(() => {
    const interval = setInterval(() => {
        setCount(count + 1); // stale closure - count is always 0
    }, 1000);
    return () => clearInterval(interval);
}, []); // count is missing from dependencies!

// GOOD - use functional update
useEffect(() => {
    const interval = setInterval(() => {
        setCount((prev) => prev + 1); // always uses latest value
    }, 1000);
    return () => clearInterval(interval);
}, []);

effect אינסופי

// BAD - infinite loop!
const [data, setData] = useState<string[]>([]);

useEffect(() => {
    setData([...data, "new item"]); // changes data, which triggers effect again
}, [data]); // data is a dependency, but effect changes data

// GOOD - use functional update to avoid dependency
useEffect(() => {
    setData((prev) => [...prev, "new item"]);
}, []); // no dependency on data

שכחת cleanup

// BAD - event listener leaks
useEffect(() => {
    window.addEventListener("resize", handleResize);
    // forgot to return cleanup!
}, []);

// GOOD
useEffect(() => {
    window.addEventListener("resize", handleResize);
    return () => window.removeEventListener("resize", handleResize);
}, []);

סיכום

  • useEffect מריץ קוד אחרי הרינדור - עבור תופעות לוואי כמו fetch, timers, events
  • מערך תלויות שולט מתי ה-effect רץ: [] פעם אחת, [x] כש-x משתנה, בלי מערך בכל רינדור
  • כל משתנה שה-effect משתמש בו חייב להיות במערך התלויות
  • פונקציית cleanup רצה לפני ה-effect הבא ובעת unmount - חשוב לנקות timers, listeners, ובקשות
  • שליפת נתונים: useEffect עם מערך תלויות ריק (או עם הפרמטרים שמשתנים)
  • useEffect לא יכול להיות async - יוצרים פונקציה async בתוכו
  • ביטול בקשות עם AbortController מונע race conditions
  • עדכון פונקציונלי (prev => ...) מונע תלות מיותרת בסטייט