React Blitz Day 3: Effects, Data Fetching & Lifecycle

Master React useEffect hook, data fetching patterns, handling side effects, cleanup functions, and avoiding common pitfalls like infinite loops.

17 min read

Duration: 1 hour

Prerequisites: Basic understanding of React components, useState, and TypeScript


Theory Part (20 minutes)

1. Understanding Side Effects

What are side effects in React?

Side effects are operations that interact with the outside world or affect something beyond the component’s scope. Examples include:

  • Fetching data from an API
  • Setting up subscriptions or timers
  • Manually manipulating the DOM
  • Logging to console
  • Reading/writing to localStorage

Pure vs. Impure Functions

Pure functions:

  • Always return the same output for the same input
  • Have no side effects
  • Don’t modify external state
// Pure function - always predictable
function add(a: number, b: number): number {
  return a + b;
}

Impure functions:

  • May produce different outputs for the same input
  • Cause side effects
  • Depend on or modify external state
// Impure function - depends on external state
let count = 0;
function increment(): number {
  count++; // Modifies external state
  return count;
}

When You Need useEffect

Use useEffect when you need to:

  • Fetch data after component mounts
  • Subscribe to external events
  • Set up timers or intervals
  • Synchronize with browser APIs (localStorage, DOM manipulation)
  • Clean up resources when component unmounts

When You DON’T Need useEffect

Avoid useEffect for:

  • Transforming data for rendering (use regular variables or useMemo)
  • Handling user events (use event handlers)
  • Calculating derived state (calculate during render)
  • Updating state based on props (update during render)
// Bad - unnecessary useEffect
function SearchResults({ query }: { query: string }) {
  const [filteredResults, setFilteredResults] = useState([]);
  
  useEffect(() => {
    setFilteredResults(results.filter(r => r.includes(query)));
  }, [query]);
  
  return <div>{/* render */}</div>;
}

// Good - calculate during render
function SearchResults({ query }: { query: string }) {
  const filteredResults = results.filter(r => r.includes(query));
  
  return <div>{/* render */}</div>;
}

2. The useEffect Hook

Basic Syntax

useEffect(() => {
  // Side effect code here
  
  return () => {
    // Cleanup code here (optional)
  };
}, [dependencies]);

Dependencies Array Variations

No dependencies array - runs after every render:

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

Empty dependencies array - runs once on mount:

useEffect(() => {
  console.log('Runs only once when component mounts');
}, []);

With dependencies - runs when dependencies change:

useEffect(() => {
  console.log('Runs when userId changes');
}, [userId]);

Cleanup Functions

Cleanup functions run:

  • Before the effect runs again
  • When the component unmounts
useEffect(() => {
  // Subscribe to something
  const subscription = api.subscribe(userId);
  
  // Cleanup function
  return () => {
    subscription.unsubscribe();
  };
}, [userId]);

Common Pitfalls and Infinite Loops

Infinite loop - missing dependencies:

// Bad - infinite loop
useEffect(() => {
  setCount(count + 1); // Updates state on every render
}); // No dependencies array

Infinite loop - dependency causes effect to run:

// Bad - infinite loop
useEffect(() => {
  setData(fetchData()); // Creates new object
}, [data]); // data changes, effect runs, data changes again...

3. Data Fetching Patterns

Fetching Data with useEffect

Basic pattern for data fetching:

function UserProfile({ userId }: { userId: number }) {
  const [user, setUser] = useState<User | null>(null);
  const [loading, setLoading] = useState(true);
  const [error, setError] = useState<Error | null>(null);

  useEffect(() => {
    // Reset states when userId changes
    setLoading(true);
    setError(null);

    fetch(`https://api.example.com/users/${userId}`)
      .then(response => {
        if (!response.ok) throw new Error('Failed to fetch');
        return response.json();
      })
      .then(data => {
        setUser(data);
        setLoading(false);
      })
      .catch(err => {
        setError(err);
        setLoading(false);
      });
  }, [userId]);

  if (loading) return <div>Loading...</div>;
  if (error) return <div>Error: {error.message}</div>;
  return <div>{user?.name}</div>;
}

Race Conditions and Cleanup

When fetching data based on changing props, you need to handle race conditions:

useEffect(() => {
  let cancelled = false; // Flag to track if effect is stale

  fetch(`https://api.example.com/users/${userId}`)
    .then(response => response.json())
    .then(data => {
      if (!cancelled) { // Only update if not cancelled
        setUser(data);
      }
    });

  return () => {
    cancelled = true; // Mark as cancelled on cleanup
  };
}, [userId]);

React 19’s use Hook for Promises

React 19 introduces the use hook for handling promises more elegantly:

import { use } from 'react';

function UserProfile({ userPromise }: { userPromise: Promise<User> }) {
  const user = use(userPromise); // Suspends until promise resolves
  
  return <div>{user.name}</div>;
}

Introduction to Suspense for Data Fetching

Suspense allows you to declaratively handle loading states:

import { Suspense } from 'react';

function App() {
  return (
    <Suspense fallback={<div>Loading user...</div>}>
      <UserProfile userPromise={fetchUser()} />
    </Suspense>
  );
}

Practice Part (35 minutes)

Exercise 1: Fetch and Display User Data (20 minutes)

Objective: Create a component that fetches and displays a list of users from the JSONPlaceholder API.

Requirements:

  • Fetch data from https://jsonplaceholder.typicode.com/users
  • Show loading state while fetching
  • Display error message if fetch fails
  • Show user list when data arrives
  • Implement proper cleanup to avoid memory leaks

Solution:

import { useState, useEffect } from 'react';

// Define the User interface
interface User {
  id: number;
  name: string;
  email: string;
  username: string;
}

function UserList() {
  // State for storing the list of users
  const [users, setUsers] = useState<User[]>([]);
  
  // State for tracking loading status
  const [loading, setLoading] = useState<boolean>(true);
  
  // State for storing any error that occurs
  const [error, setError] = useState<string | null>(null);

  useEffect(() => {
    // Flag to track if the component is still mounted
    let isMounted = true;

    // Function to fetch users from the API
    const fetchUsers = async () => {
      try {
        // Start loading
        setLoading(true);
        setError(null);

        // Fetch data from JSONPlaceholder API
        const response = await fetch('https://jsonplaceholder.typicode.com/users');
        
        // Check if response is successful
        if (!response.ok) {
          throw new Error(`HTTP error! status: ${response.status}`);
        }

        // Parse JSON response
        const data: User[] = await response.json();

        // Only update state if component is still mounted
        if (isMounted) {
          setUsers(data);
          setLoading(false);
        }
      } catch (err) {
        // Handle any errors that occurred during fetch
        if (isMounted) {
          setError(err instanceof Error ? err.message : 'An error occurred');
          setLoading(false);
        }
      }
    };

    // Call the fetch function
    fetchUsers();

    // Cleanup function - runs when component unmounts or effect re-runs
    return () => {
      isMounted = false; // Prevent state updates after unmount
    };
  }, []); // Empty dependency array - run only once on mount

  // Render loading state
  if (loading) {
    return (
      <div className="loading">
        <p>Loading users...</p>
      </div>
    );
  }

  // Render error state
  if (error) {
    return (
      <div className="error">
        <p>Error: {error}</p>
        <button onClick={() => window.location.reload()}>Retry</button>
      </div>
    );
  }

  // Render users list
  return (
    <div className="user-list">
      <h2>Users ({users.length})</h2>
      <ul>
        {users.map(user => (
          <li key={user.id}>
            <div className="user-card">
              <h3>{user.name}</h3>
              <p>Username: @{user.username}</p>
              <p>Email: {user.email}</p>
            </div>
          </li>
        ))}
      </ul>
    </div>
  );
}

export default UserList;

Key Points:

  • Used isMounted flag to prevent state updates after component unmounts
  • Separated concerns: loading, error, and success states
  • Used async/await for cleaner asynchronous code
  • Proper error handling with try/catch
  • Cleanup function prevents memory leaks

Exercise 2: Search with Debouncing (15 minutes)

Objective: Build a search component that fetches results as the user types, with debouncing to avoid excessive API calls.

Requirements:

  • Input field for search terms
  • Fetch results from https://jsonplaceholder.typicode.com/users?name_like={searchTerm}
  • Implement debouncing (wait 500ms after typing stops)
  • Show loading indicator during search
  • Handle cleanup properly

Solution:

import { useState, useEffect } from 'react';

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

function UserSearch() {
  // State for the search input value
  const [searchTerm, setSearchTerm] = useState<string>('');
  
  // State for the debounced search term (used for API calls)
  const [debouncedTerm, setDebouncedTerm] = useState<string>('');
  
  // State for search results
  const [results, setResults] = useState<User[]>([]);
  
  // State for loading indicator
  const [loading, setLoading] = useState<boolean>(false);
  
  // State for error handling
  const [error, setError] = useState<string | null>(null);

  // Effect for debouncing the search term
  useEffect(() => {
    // Set up a timer to update debouncedTerm after 500ms
    const timerId = setTimeout(() => {
      setDebouncedTerm(searchTerm);
    }, 500);

    // Cleanup function - clear the timer if searchTerm changes
    // This prevents the update if user types again within 500ms
    return () => {
      clearTimeout(timerId);
    };
  }, [searchTerm]); // Re-run when searchTerm changes

  // Effect for fetching search results
  useEffect(() => {
    // Don't search if the term is empty or too short
    if (debouncedTerm.length < 2) {
      setResults([]);
      return;
    }

    // Flag to track if the effect is still active
    let isCancelled = false;

    // Async function to fetch search results
    const searchUsers = async () => {
      try {
        setLoading(true);
        setError(null);

        // Fetch all users (JSONPlaceholder doesn't support name_like, so we filter client-side)
        const response = await fetch('https://jsonplaceholder.typicode.com/users');
        
        if (!response.ok) {
          throw new Error('Failed to fetch users');
        }

        const data: User[] = await response.json();

        // Filter results based on search term
        const filteredResults = data.filter(user =>
          user.name.toLowerCase().includes(debouncedTerm.toLowerCase()) ||
          user.username.toLowerCase().includes(debouncedTerm.toLowerCase())
        );

        // Only update state if effect hasn't been cancelled
        if (!isCancelled) {
          setResults(filteredResults);
          setLoading(false);
        }
      } catch (err) {
        if (!isCancelled) {
          setError(err instanceof Error ? err.message : 'Search failed');
          setLoading(false);
        }
      }
    };

    // Execute the search
    searchUsers();

    // Cleanup function - cancel the effect if debouncedTerm changes
    return () => {
      isCancelled = true;
    };
  }, [debouncedTerm]); // Re-run when debouncedTerm changes

  return (
    <div className="user-search">
      <h2>Search Users</h2>
      
      {/* Search input field */}
      <input
        type="text"
        placeholder="Search by name or username..."
        value={searchTerm}
        onChange={(e) => setSearchTerm(e.target.value)}
        className="search-input"
      />

      {/* Loading indicator */}
      {loading && <p className="loading-text">Searching...</p>}

      {/* Error message */}
      {error && <p className="error-text">Error: {error}</p>}

      {/* Search results */}
      {!loading && searchTerm.length >= 2 && (
        <div className="results">
          <p>{results.length} result(s) found</p>
          <ul>
            {results.map(user => (
              <li key={user.id}>
                <div className="result-card">
                  <h3>{user.name}</h3>
                  <p>@{user.username}</p>
                  <p>{user.email}</p>
                </div>
              </li>
            ))}
          </ul>
        </div>
      )}

      {/* No results message */}
      {!loading && searchTerm.length >= 2 && results.length === 0 && (
        <p>No users found matching "{searchTerm}"</p>
      )}
    </div>
  );
}

export default UserSearch;

Key Points:

  • Two-stage state management: searchTerm (immediate) and debouncedTerm (delayed)
  • First useEffect implements debouncing with setTimeout
  • Second useEffect performs the actual search
  • Cleanup functions prevent race conditions and memory leaks
  • Minimum search term length validation
  • Client-side filtering (since JSONPlaceholder doesn’t support query parameters)

Quick Review - Key Concepts (5 minutes)

1. useEffect Runs After Render

Effects don’t block the browser from painting the screen. They run after the DOM has been updated, allowing React to keep the UI responsive.

function Component() {
  console.log('1. Render');
  
  useEffect(() => {
    console.log('3. Effect runs');
  });
  
  console.log('2. Still rendering');
  
  return <div>Hello</div>;
}
// Output: 1. Render -> 2. Still rendering -> 3. Effect runs

2. Dependencies Array Controls When Effects Run

DependenciesBehaviorUse Case
NoneRuns after every renderRarely needed, usually a mistake
[]Runs once on mountInitial data fetching, subscriptions
[dep1, dep2]Runs when dependencies changeFetch data based on props/state

3. Always Clean Up

Return a cleanup function for:

  • Timers and intervals
  • Event listeners
  • Subscriptions
  • Pending API requests
  • WebSocket connections
useEffect(() => {
  const interval = setInterval(() => console.log('tick'), 1000);
  
  return () => clearInterval(interval); // Cleanup
}, []);

4. Don’t Overuse useEffect

Many scenarios don’t need useEffect:

// Bad - unnecessary effect
function Total({ price, quantity }: Props) {
  const [total, setTotal] = useState(0);
  
  useEffect(() => {
    setTotal(price * quantity);
  }, [price, quantity]);
  
  return <div>{total}</div>;
}

// Good - calculate during render
function Total({ price, quantity }: Props) {
  const total = price * quantity;
  
  return <div>{total}</div>;
}

Checklist - What You Should Know After Day 3

  • ✅ Use useEffect for side effects (data fetching, subscriptions, timers)
  • ✅ Understand the dependencies array and when effects run
  • ✅ Fetch data from APIs with proper error handling
  • ✅ Handle loading and error states in your components
  • ✅ Implement cleanup functions to prevent memory leaks
  • ✅ Avoid common useEffect pitfalls (infinite loops, missing dependencies)
  • ✅ Know when NOT to use useEffect (derived state, event handlers)

Quick Reference Card - Save This!

// Run once on mount
useEffect(() => {
  fetchData();
}, []);

// Run when dependency changes
useEffect(() => {
  fetchData(id);
}, [id]);

// Cleanup example
useEffect(() => {
  const timer = setTimeout(() => {
    console.log('Delayed action');
  }, 1000);
  
  // Cleanup function
  return () => clearTimeout(timer);
}, []);

// Complete data fetching pattern
function DataComponent({ url }: { url: string }) {
  const [data, setData] = useState<Data | null>(null);
  const [loading, setLoading] = useState<boolean>(true);
  const [error, setError] = useState<Error | null>(null);

  useEffect(() => {
    // Cancellation flag
    let cancelled = false;
    
    // Reset states
    setLoading(true);
    setError(null);
    
    // Fetch data
    fetch(url)
      .then(res => {
        if (!res.ok) throw new Error('Fetch failed');
        return res.json();
      })
      .then(data => {
        if (!cancelled) {
          setData(data);
          setLoading(false);
        }
      })
      .catch(err => {
        if (!cancelled) {
          setError(err);
          setLoading(false);
        }
      });
    
    // Cleanup function
    return () => {
      cancelled = true;
    };
  }, [url]);

  if (loading) return <div>Loading...</div>;
  if (error) return <div>Error: {error.message}</div>;
  return <div>{/* render data */}</div>;
}

// Debouncing pattern
useEffect(() => {
  const timerId = setTimeout(() => {
    // Action after delay
    performSearch(searchTerm);
  }, 500);

  return () => clearTimeout(timerId);
}, [searchTerm]);

Common Mistakes to Avoid

1. Missing Dependencies

// Bad - userId is used but not in dependencies
useEffect(() => {
  fetchUser(userId);
}, []);

// Good - include all dependencies
useEffect(() => {
  fetchUser(userId);
}, [userId]);

2. Forgetting Cleanup

// Bad - timer keeps running after unmount
useEffect(() => {
  setInterval(() => console.log('tick'), 1000);
}, []);

// Good - cleanup the timer
useEffect(() => {
  const id = setInterval(() => console.log('tick'), 1000);
  return () => clearInterval(id);
}, []);

3. Using Objects/Arrays as Dependencies

// Bad - object is recreated on every render
const config = { url: '/api/data' };
useEffect(() => {
  fetch(config.url);
}, [config]); // Infinite loop!

// Good - use primitive values
useEffect(() => {
  fetch(config.url);
}, [config.url]);

What’s Tomorrow?

On Day 4, we’ll dive into:

  • Complex state management with useReducer
  • Context API for global state
  • Composition patterns and component architecture
  • When to use Context vs. Props
  • Performance optimization with Context

Additional Resources


Congratulations! You’ve completed Day 3 of the React course. You now understand how to handle side effects, fetch data from APIs, and manage component lifecycle with useEffect.

Back to blog