20 minlesson

Effect Patterns and Best Practices

Effect Patterns and Best Practices

Now that you understand useEffect basics, let's explore advanced patterns, common pitfalls, and when to avoid effects altogether.

Effect Timing: Commit vs Paint

React provides two effect hooks with different timing:

jsx
1import { useEffect, useLayoutEffect } from "react";
2
3// useEffect: Runs AFTER paint (non-blocking)
4useEffect(() => {
5 // User sees the screen, then this runs
6 // Good for: data fetching, subscriptions, logging
7});
8
9// useLayoutEffect: Runs BEFORE paint (blocking)
10useLayoutEffect(() => {
11 // This runs before user sees anything
12 // Good for: measuring DOM, preventing flicker
13 const height = ref.current.getBoundingClientRect().height;
14 setHeight(height);
15});

Rule: Use useEffect by default. Only use useLayoutEffect when you need to measure or mutate the DOM before paint.

Debouncing in Effects

Delay execution until user stops typing:

jsx
1function SearchInput({ onSearch }) {
2 const [query, setQuery] = useState("");
3
4 useEffect(() => {
5 // Don't search if empty
6 if (!query) return;
7
8 // Set up debounce timer
9 const timeoutId = setTimeout(() => {
10 onSearch(query);
11 }, 300); // Wait 300ms after last keystroke
12
13 // Cleanup: cancel if query changes before timeout
14 return () => clearTimeout(timeoutId);
15 }, [query, onSearch]);
16
17 return (
18 <input
19 value={query}
20 onChange={(e) => setQuery(e.target.value)}
21 placeholder="Search..."
22 />
23 );
24}

Each keystroke:

  1. Cancels the previous timeout (cleanup)
  2. Starts a new 300ms timeout
  3. Only fires search after 300ms of no typing

Throttling in Effects

Limit how often an effect can run:

jsx
1function ScrollTracker() {
2 const [scrollY, setScrollY] = useState(0);
3 const lastUpdate = useRef(0);
4
5 useEffect(() => {
6 const handleScroll = () => {
7 const now = Date.now();
8 // Only update every 100ms
9 if (now - lastUpdate.current >= 100) {
10 setScrollY(window.scrollY);
11 lastUpdate.current = now;
12 }
13 };
14
15 window.addEventListener("scroll", handleScroll);
16 return () => window.removeEventListener("scroll", handleScroll);
17 }, []);
18
19 return <p>Scroll position: {scrollY}px</p>;
20}

Handling Stale Closures

Effects capture values from when they were created:

jsx
1function Counter() {
2 const [count, setCount] = useState(0);
3
4 useEffect(() => {
5 const id = setInterval(() => {
6 // BUG: count is always 0 (captured at mount)
7 console.log("Count:", count);
8 setCount(count + 1); // Always sets to 1!
9 }, 1000);
10
11 return () => clearInterval(id);
12 }, []); // Empty deps = effect never re-runs
13
14 return <p>{count}</p>;
15}

Approaches:

jsx
1// Approach 1: Add count to dependencies (effect re-runs)
2useEffect(() => {
3 const id = setInterval(() => {
4 setCount(count + 1);
5 }, 1000);
6 return () => clearInterval(id);
7}, [count]); // Re-runs when count changes
8
9// Approach 2: Functional update (preferred for this case)
10useEffect(() => {
11 const id = setInterval(() => {
12 setCount((c) => c + 1); // Always uses latest value
13 }, 1000);
14 return () => clearInterval(id);
15}, []); // No dependency needed
16
17// Approach 3: Use ref for latest value
18const countRef = useRef(count);
19countRef.current = count; // Update ref on every render
20
21useEffect(() => {
22 const id = setInterval(() => {
23 console.log("Count:", countRef.current); // Always current
24 }, 1000);
25 return () => clearInterval(id);
26}, []);

Multiple Effects

Separate unrelated logic into multiple effects:

jsx
1function UserDashboard({ userId }) {
2 const [user, setUser] = useState(null);
3 const [posts, setPosts] = useState([]);
4
5 // Effect 1: Fetch user data
6 useEffect(() => {
7 fetchUser(userId).then(setUser);
8 }, [userId]);
9
10 // Effect 2: Fetch posts (separate concern)
11 useEffect(() => {
12 fetchPosts(userId).then(setPosts);
13 }, [userId]);
14
15 // Effect 3: Update document title (different lifecycle)
16 useEffect(() => {
17 if (user) {
18 document.title = `${user.name}'s Dashboard`;
19 }
20 }, [user]);
21
22 // ...
23}

Benefits:

  • Easier to understand each effect's purpose
  • Different dependencies for different concerns
  • Easier to extract into custom hooks

Synchronizing with External Systems

Effects are for synchronizing React with external systems:

jsx
1// Sync with browser API
2function useOnlineStatus() {
3 const [isOnline, setIsOnline] = useState(navigator.onLine);
4
5 useEffect(() => {
6 const handleOnline = () => setIsOnline(true);
7 const handleOffline = () => setIsOnline(false);
8
9 window.addEventListener("online", handleOnline);
10 window.addEventListener("offline", handleOffline);
11
12 return () => {
13 window.removeEventListener("online", handleOnline);
14 window.removeEventListener("offline", handleOffline);
15 };
16 }, []);
17
18 return isOnline;
19}
20
21// Sync with WebSocket
22function useChatRoom({ roomId }) {
23 useEffect(() => {
24 const connection = createConnection(roomId);
25 connection.connect();
26
27 return () => connection.disconnect();
28 }, [roomId]);
29}
30
31// Sync with third-party library
32function useMapMarker({ map, position }) {
33 useEffect(() => {
34 const marker = new google.maps.Marker({ position, map });
35
36 return () => marker.setMap(null);
37 }, [map, position]);
38}

Avoiding Effect Chains

Don't chain effects to transform data:

jsx
1// BAD - Effect chains
2function ProductPage({ productId }) {
3 const [product, setProduct] = useState(null);
4 const [category, setCategory] = useState(null);
5 const [relatedProducts, setRelatedProducts] = useState(null);
6
7 // Chain 1: Fetch product
8 useEffect(() => {
9 fetchProduct(productId).then(setProduct);
10 }, [productId]);
11
12 // Chain 2: Fetch category when product loads
13 useEffect(() => {
14 if (product) {
15 fetchCategory(product.categoryId).then(setCategory);
16 }
17 }, [product]);
18
19 // Chain 3: Fetch related when category loads
20 useEffect(() => {
21 if (category) {
22 fetchRelated(category.id).then(setRelatedProducts);
23 }
24 }, [category]);
25}
26
27// GOOD - Single effect with async/await
28function ProductPage({ productId }) {
29 const [data, setData] = useState(null);
30 const [loading, setLoading] = useState(true);
31
32 useEffect(() => {
33 async function loadData() {
34 setLoading(true);
35
36 const product = await fetchProduct(productId);
37 const category = await fetchCategory(product.categoryId);
38 const related = await fetchRelated(category.id);
39
40 setData({ product, category, related });
41 setLoading(false);
42 }
43
44 loadData();
45 }, [productId]);
46}

Strict Mode and Double Effects

In development with Strict Mode, effects run twice:

jsx
1// In StrictMode, this logs twice on mount:
2// "Mounted"
3// "Cleanup"
4// "Mounted"
5
6useEffect(() => {
7 console.log("Mounted");
8 return () => console.log("Cleanup");
9}, []);

This helps find bugs with missing cleanup. Your effect should:

  • Work correctly if run multiple times
  • Clean up properly between runs

Common Mistakes

1. Object/Array Dependencies

jsx
1// BAD - new object every render = infinite loop!
2useEffect(() => {
3 fetchData(options);
4}, [{ page: 1 }]); // New object reference each time
5
6// GOOD - use primitives or useMemo
7const page = 1;
8useEffect(() => {
9 fetchData({ page });
10}, [page]);

2. Missing Cleanup

jsx
1// BAD - memory leak!
2useEffect(() => {
3 const handler = () => {
4 /* ... */
5 };
6 window.addEventListener("resize", handler);
7 // Missing: return () => window.removeEventListener...
8}, []);

3. Fetching Without Handling Unmount

jsx
1// BAD - state update after unmount
2useEffect(() => {
3 fetch("/api/data")
4 .then((r) => r.json())
5 .then(setData); // Component may have unmounted!
6}, []);
7
8// GOOD - check if still mounted
9useEffect(() => {
10 let mounted = true;
11
12 fetch("/api/data")
13 .then((r) => r.json())
14 .then((data) => {
15 if (mounted) setData(data);
16 });
17
18 return () => {
19 mounted = false;
20 };
21}, []);

When to Use Effects

DO use effects for:

  • Fetching data
  • Setting up subscriptions
  • Synchronizing with external systems
  • Tracking analytics
  • Managing focus

DON'T use effects for:

  • Computing derived data (calculate during render)
  • Handling user events (use event handlers)
  • Initializing state (use lazy initial state)
  • Resetting state when props change (use key)

Summary

Effect best practices:

  1. Debounce/throttle expensive operations
  2. Handle stale closures with functional updates or refs
  3. Separate unrelated effects by concern
  4. Avoid effect chains - batch related fetches
  5. Always clean up subscriptions and timers
  6. Handle unmounting in async operations
  7. Test with Strict Mode to catch bugs
  8. Don't overuse effects - many cases don't need them

Next, let's build a live search feature that applies these patterns!