Component Lifecycle and Effects

Understanding the React component lifecycle and managing side effects in functional components

Introduction to Component Lifecycle

Every React component goes through a series of stages during its existence, collectively known as the "component lifecycle." Understanding this lifecycle is crucial for controlling when certain operations happen in your application.

In class components, React provided explicit lifecycle methods like componentDidMount, componentDidUpdate, and componentWillUnmount. In functional components, we manage these lifecycle events using the useEffect Hook.

flowchart TD A[Component Lifecycle] --> B[Mounting] A --> C[Updating] A --> D[Unmounting] B --> B1[Component created] B --> B2[JSX rendered] B --> B3[DOM updated] B --> B4[useEffect runs] C --> C1[Props change] C --> C2[State changes] C --> C3[Parent re-renders] C --> C4[Component re-renders] C --> C5[useEffect cleanup] C --> C6[useEffect runs again] D --> D1[Component removed] D --> D2[useEffect cleanup] style A fill:#f9f,stroke:#333,stroke-width:2px

Side Effects in React

In React, a "side effect" is any operation that affects something outside the scope of the current function being executed. Common examples include:

Real-world analogy: Think of a side effect like ordering food at a restaurant. The main function of your visit is to eat, but you need to perform the side effect of placing an order to make that happen. Similarly, your component's main job is to render UI, but it often needs to perform side effects to get or set up the data it needs.

The useEffect Hook

The useEffect Hook lets you perform side effects in functional components. It serves the same purpose as componentDidMount, componentDidUpdate, and componentWillUnmount in React class components, but unified into a single API.

Basic Syntax


import React, { useEffect } from 'react';

function MyComponent() {
  useEffect(() => {
    // Code to run after render
    console.log('Component rendered');
    
    // Optional cleanup function
    return () => {
      console.log('Component cleanup');
    };
  }, [/* dependency array */]);
  
  return <div>My Component</div>;
}
      

The Dependency Array

The second argument to useEffect is an array of dependencies. It controls when the effect runs:

Dependency Array Behavior Equivalent Class Method
No dependency array Effect runs after every render componentDidMount + componentDidUpdate
Empty array [] Effect runs only after the first render componentDidMount
Array with values [a, b] Effect runs after first render and when a or b change componentDidMount + conditional componentDidUpdate

useEffect for Component Mounting

To run an effect only when a component mounts (is first rendered), use an empty dependency array.

Example: Fetching Initial Data


function UserProfile({ userId }) {
  const [user, setUser] = useState(null);
  const [loading, setLoading] = useState(true);
  const [error, setError] = useState(null);
  
  useEffect(() => {
    // This effect runs once when the component mounts
    async function fetchUser() {
      try {
        setLoading(true);
        const response = await fetch(`https://api.example.com/users/${userId}`);
        if (!response.ok) throw new Error('Failed to fetch user');
        const data = await response.json();
        setUser(data);
      } catch (err) {
        setError(err.message);
      } finally {
        setLoading(false);
      }
    }
    
    fetchUser();
  }, []); // Empty dependency array means "only run on mount"
  
  if (loading) return <div>Loading...</div>;
  if (error) return <div>Error: {error}</div>;
  if (!user) return null;
  
  return (
    <div>
      <h2>{user.name}</h2>
      <p>Email: {user.email}</p>
      <p>Bio: {user.bio}</p>
    </div>
  );
}
      

Note: In this example, we actually have a dependency on userId, which should be included in the dependency array. We'll address this in the next section.

Common Mount-Only Operations

useEffect for Component Updates

To run an effect when specific values change (like props or state), include those values in the dependency array.

Example: Data Fetching on Prop Change


function UserProfile({ userId }) {
  const [user, setUser] = useState(null);
  const [loading, setLoading] = useState(true);
  const [error, setError] = useState(null);
  
  // This effect runs when the component mounts AND when userId changes
  useEffect(() => {
    let isMounted = true; // For cleanup - prevent state updates if unmounted
    
    async function fetchUser() {
      try {
        setLoading(true);
        const response = await fetch(`https://api.example.com/users/${userId}`);
        if (!response.ok) throw new Error('Failed to fetch user');
        const data = await response.json();
        
        // Only update state if component is still mounted
        if (isMounted) {
          setUser(data);
          setLoading(false);
        }
      } catch (err) {
        if (isMounted) {
          setError(err.message);
          setLoading(false);
        }
      }
    }
    
    fetchUser();
    
    // Cleanup function
    return () => {
      isMounted = false;
    };
  }, [userId]); // Dependency array with userId
  
  // Rest of component remains the same
}
      

Real-world example: This pattern is similar to how social media profiles work. When you navigate from one user's profile to another, the UI updates to fetch and display the new user's information.

React 18+ Note:

In React 18+, setting state in unmounted components is no longer a memory leak issue, so the isMounted approach above isn't strictly necessary. However, it's still a good practice to avoid performing unnecessary operations after a component unmounts.

Handling Multiple Dependencies


function ProductList({ category, sortBy, page }) {
  const [products, setProducts] = useState([]);
  
  useEffect(() => {
    fetchProducts(category, sortBy, page)
      .then(data => setProducts(data));
  }, [category, sortBy, page]); // Effect runs when any of these change
  
  return (
    <div>
      {products.map(product => (
        <ProductCard key={product.id} product={product} />
      ))}
    </div>
  );
}
      

useEffect for Cleanup (Unmounting)

The cleanup function (returned by your effect) runs before the component unmounts, and also before the effect runs again if it has dependencies that change.

Example: Event Listener Cleanup


function WindowSizeTracker() {
  const [windowSize, setWindowSize] = useState({
    width: window.innerWidth,
    height: window.innerHeight
  });
  
  useEffect(() => {
    // Define the event handler
    function handleResize() {
      setWindowSize({
        width: window.innerWidth,
        height: window.innerHeight
      });
    }
    
    // Add event listener
    window.addEventListener('resize', handleResize);
    
    // Return cleanup function
    return () => {
      // Remove event listener on unmount
      window.removeEventListener('resize', handleResize);
    };
  }, []); // Empty dependency array - only run on mount/unmount
  
  return (
    <div>
      <p>Window width: {windowSize.width}px</p>
      <p>Window height: {windowSize.height}px</p>
    </div>
  );
}
      

Real-world analogy: If you set up a subscription to a newspaper when you move into an apartment, you should cancel that subscription when you move out. The cleanup function is like telling the post office to stop delivering the newspaper to your old address.

Common Cleanup Operations

Effect Execution Timing

sequenceDiagram participant Component participant React participant Effect participant DOM Component->>React: Render React->>DOM: Update DOM Note over DOM: Browser paints screen React->>Effect: Run Effect Note over Effect: May cause state update Effect->>Component: Update state (if needed) Component->>React: Re-render

Important things to understand about effect timing:

  1. Effects run after the render is committed to the screen
  2. Effects are deferred until after the browser has painted
  3. Effects that update state will trigger another render cycle

Why this matters: Understanding the timing helps you avoid infinite loops and ensures your UI remains responsive. Since effects run after painting, they don't block the browser from updating the screen.

Multiple Effects

You can use multiple useEffect hooks in a single component to separate concerns.


function UserDashboard({ userId }) {
  // State for user profile
  const [user, setUser] = useState(null);
  
  // State for activity feed
  const [activities, setActivities] = useState([]);
  
  // State for online status
  const [isOnline, setIsOnline] = useState(navigator.onLine);
  
  // Effect for fetching user data
  useEffect(() => {
    fetchUser(userId).then(data => setUser(data));
  }, [userId]);
  
  // Separate effect for fetching activity feed
  useEffect(() => {
    if (!user) return; // Only fetch activities if we have a user
    
    fetchActivities(user.id).then(data => setActivities(data));
  }, [user]);
  
  // Separate effect for tracking online status
  useEffect(() => {
    function handleOnline() {
      setIsOnline(true);
    }
    
    function handleOffline() {
      setIsOnline(false);
    }
    
    window.addEventListener('online', handleOnline);
    window.addEventListener('offline', handleOffline);
    
    return () => {
      window.removeEventListener('online', handleOnline);
      window.removeEventListener('offline', handleOffline);
    };
  }, []); // Empty dependency array - only on mount/unmount
  
  return (
    <div>
      {/* Dashboard UI */}
    </div>
  );
}
      

Why separate effects? It makes your code more maintainable by grouping related functionality. Each effect encapsulates a specific concern, making it easier to understand and modify.

Advanced useEffect Patterns

Debouncing

Debouncing is useful when you want to limit how often an effect runs, such as when handling user input.


function SearchComponent() {
  const [searchTerm, setSearchTerm] = useState('');
  const [results, setResults] = useState([]);
  const [isSearching, setIsSearching] = useState(false);
  
  // Debounced search effect
  useEffect(() => {
    if (!searchTerm) {
      setResults([]);
      return;
    }
    
    setIsSearching(true);
    
    // Create a timeout to delay the search
    const timeoutId = setTimeout(() => {
      performSearch(searchTerm)
        .then(data => {
          setResults(data);
          setIsSearching(false);
        });
    }, 500); // 500ms delay
    
    // Clean up the timeout if searchTerm changes before timeout completes
    return () => clearTimeout(timeoutId);
  }, [searchTerm]);
  
  return (
    <div>
      <input 
        type="text"
        value={searchTerm}
        onChange={e => setSearchTerm(e.target.value)}
        placeholder="Search..."
      />
      
      {isSearching && <p>Searching...</p>}
      
      <ul>
        {results.map(item => (
          <li key={item.id}>{item.name}</li>
        ))}
      </ul>
    </div>
  );
}
      

Real-world example: Think of typing in Google's search box. It doesn't send a request for every keystroke, but waits until you pause typing before fetching results.

Async Effect with Cleanup

Handling race conditions when dealing with asynchronous operations:


function UserProfile({ userId }) {
  const [user, setUser] = useState(null);
  
  useEffect(() => {
    let isCurrent = true; // Flag to track if component is current
    
    // Define async function inside effect
    async function fetchUserData() {
      try {
        const response = await fetch(`https://api.example.com/users/${userId}`);
        const userData = await response.json();
        
        // Only update state if this is still the current render
        if (isCurrent) {
          setUser(userData);
        }
      } catch (error) {
        if (isCurrent) {
          console.error("Failed to fetch user:", error);
        }
      }
    }
    
    fetchUserData();
    
    // Cleanup function sets flag to false when unmounting or re-running effect
    return () => {
      isCurrent = false;
    };
  }, [userId]);
  
  // Rest of component...
}
      

This pattern is crucial when dealing with asynchronous operations that might complete after the component has unmounted or after a new effect has started.

Common useEffect Mistakes

Missing Dependencies

❌ Incorrect


function Counter({ initial }) {
  const [count, setCount] = useState(initial);
  
  // Missing dependency on 'initial'
  useEffect(() => {
    setCount(initial);
  }, []); // Should include 'initial'
}
          

This effect won't run when the 'initial' prop changes.

✅ Correct


function Counter({ initial }) {
  const [count, setCount] = useState(initial);
  
  useEffect(() => {
    setCount(initial);
  }, [initial]); // Includes all dependencies
}
          

Infinite Loops

❌ Incorrect


function InfiniteLoop() {
  const [count, setCount] = useState(0);
  
  // This creates an infinite loop!
  useEffect(() => {
    setCount(count + 1);
  }, [count]); // Effect updates count, triggering effect again
}
          

✅ Correct


function Counter() {
  const [count, setCount] = useState(0);
  
  useEffect(() => {
    // Only increment once when mounted
    if (count === 0) {
      setCount(1);
    }
  }, [count]);
  
  // Or better, use a different approach:
  useEffect(() => {
    setCount(1); // Set initial value once
  }, []); // Empty dependency array - run once on mount
}
          

Stale Closures

❌ Incorrect


function Timer() {
  const [count, setCount] = useState(0);
  
  useEffect(() => {
    const interval = setInterval(() => {
      // This closure captures the initial value of count (0)
      // and will always increment from that value
      setCount(count + 1);
    }, 1000);
    
    return () => clearInterval(interval);
  }, []); // Empty dependency array means interval uses stale 'count'
}
          

✅ Correct


function Timer() {
  const [count, setCount] = useState(0);
  
  useEffect(() => {
    const interval = setInterval(() => {
      // Use functional update to get the latest state
      setCount(prevCount => prevCount + 1);
    }, 1000);
    
    return () => clearInterval(interval);
  }, []); // Empty dependency array is fine with functional updates
}
          

Real-World Applications

Form with Auto-Save


function AutoSaveForm() {
  const [formData, setFormData] = useState({
    title: '',
    content: ''
  });
  const [isSaving, setIsSaving] = useState(false);
  const [lastSaved, setLastSaved] = useState(null);
  
  // Handle form changes
  const handleChange = (e) => {
    const { name, value } = e.target;
    setFormData(prev => ({
      ...prev,
      [name]: value
    }));
  };
  
  // Auto-save effect
  useEffect(() => {
    // Don't save if form is empty
    if (!formData.title && !formData.content) return;
    
    // Set a timeout for auto-save
    const timeoutId = setTimeout(() => {
      setIsSaving(true);
      
      // Simulate API call
      saveToServer(formData)
        .then(() => {
          setLastSaved(new Date());
          setIsSaving(false);
        })
        .catch(error => {
          console.error('Failed to save:', error);
          setIsSaving(false);
        });
    }, 2000); // Auto-save 2 seconds after typing stops
    
    // Clean up timeout if user continues typing
    return () => clearTimeout(timeoutId);
  }, [formData]);
  
  return (
    <form>
      <div>
        <label htmlFor="title">Title:</label>
        <input
          id="title"
          name="title"
          value={formData.title}
          onChange={handleChange}
        />
      </div>
      
      <div>
        <label htmlFor="content">Content:</label>
        <textarea
          id="content"
          name="content"
          value={formData.content}
          onChange={handleChange}
          rows="6"
        />
      </div>
      
      <div>
        {isSaving ? (
          <span>Saving...</span>
        ) : lastSaved ? (
          <span>Last saved: {lastSaved.toLocaleTimeString()}</span>
        ) : null}
      </div>
    </form>
  );
}

// Mock function for the example
function saveToServer(data) {
  return new Promise(resolve => {
    setTimeout(resolve, 500);
  });
}
      

Geolocation Tracking


function LocationTracker() {
  const [location, setLocation] = useState(null);
  const [error, setError] = useState(null);
  
  useEffect(() => {
    // Check if geolocation is supported
    if (!navigator.geolocation) {
      setError('Geolocation is not supported by your browser');
      return;
    }
    
    // Success handler
    const handleSuccess = (position) => {
      const { latitude, longitude } = position.coords;
      setLocation({ latitude, longitude });
      setError(null);
    };
    
    // Error handler
    const handleError = (error) => {
      setError(`Failed to get location: ${error.message}`);
    };
    
    // Options for geolocation
    const options = {
      enableHighAccuracy: true,
      maximumAge: 30000,
      timeout: 10000
    };
    
    // Watch position (continuous updates)
    const watchId = navigator.geolocation.watchPosition(
      handleSuccess, 
      handleError,
      options
    );
    
    // Cleanup: stop watching position
    return () => {
      navigator.geolocation.clearWatch(watchId);
    };
  }, []); // Empty dependency array - only on mount/unmount
  
  return (
    <div>
      {error ? (
        <p>Error: {error}</p>
      ) : !location ? (
        <p>Getting your location...</p>
      ) : (
        <div>
          <p>Your coordinates:</p>
          <p>Latitude: {location.latitude}</p>
          <p>Longitude: {location.longitude}</p>
          <a 
            href={`https://maps.google.com/?q=${location.latitude},${location.longitude}`}
            target="_blank"
            rel="noopener noreferrer"
          >
            View on Google Maps
          </a>
        </div>
      )}
    </div>
  );
}
      

WebSocket Chat Connection


function ChatRoom({ roomId, username }) {
  const [messages, setMessages] = useState([]);
  const [connected, setConnected] = useState(false);
  const [inputMessage, setInputMessage] = useState('');
  
  // WebSocket connection effect
  useEffect(() => {
    // Create WebSocket connection
    const socket = new WebSocket('wss://example.com/chat');
    
    // Connection opened
    socket.addEventListener('open', () => {
      setConnected(true);
      
      // Join specific room
      socket.send(JSON.stringify({
        type: 'join',
        roomId,
        username
      }));
    });
    
    // Listen for messages
    socket.addEventListener('message', (event) => {
      const message = JSON.parse(event.data);
      
      if (message.type === 'chat') {
        setMessages(prev => [...prev, message]);
      }
    });
    
    // Connection closed or error
    socket.addEventListener('close', () => {
      setConnected(false);
    });
    
    socket.addEventListener('error', () => {
      setConnected(false);
    });
    
    // Cleanup on unmount or when roomId changes
    return () => {
      // Send leave message if connected
      if (socket.readyState === WebSocket.OPEN) {
        socket.send(JSON.stringify({
          type: 'leave',
          roomId,
          username
        }));
      }
      
      // Close connection
      socket.close();
    };
  }, [roomId, username]); // Re-connect if room or username changes
  
  // Send message function
  const sendMessage = (e) => {
    e.preventDefault();
    if (!inputMessage.trim() || !connected) return;
    
    const socket = new WebSocket('wss://example.com/chat');
    socket.send(JSON.stringify({
      type: 'chat',
      roomId,
      username,
      message: inputMessage
    }));
    
    setInputMessage('');
  };
  
  return (
    <div className="chat-room">
      <div className="status">
        {connected ? 'Connected' : 'Disconnected'}
      </div>
      
      <div className="messages">
        {messages.map((msg, index) => (
          <div key={index} className={`message ${msg.username === username ? 'own' : ''}`}>
            <strong>{msg.username}:</strong> {msg.message}
          </div>
        ))}
      </div>
      
      <form onSubmit={sendMessage}>
        <input
          type="text"
          value={inputMessage}
          onChange={e => setInputMessage(e.target.value)}
          placeholder="Type a message..."
          disabled={!connected}
        />
        <button type="submit" disabled={!connected}>Send</button>
      </form>
    </div>
  );
}
      

Practice Exercises

Exercise 1: Data Fetching

Create a component that fetches data from the JSONPlaceholder API (https://jsonplaceholder.typicode.com/posts) and displays the results. Include:

Exercise 2: Interval Timer

Build a timer component that updates every second and includes:

Exercise 3: Form with Validation

Create a form that validates input as the user types with a slight delay (debounce) and:

Summary

In this lecture, we've covered:

The useEffect Hook is a powerful tool for handling side effects in React. By understanding when and how effects run, you can build components that interact cleanly with external systems while maintaining a good user experience.

Further Resources