javascript-today

useEffect Hook

useEffect - Side Effects in React

The useEffect hook lets you perform side effects in function components. Side effects are operations that interact with the outside world: data fetching, subscriptions, timers, manual DOM manipulation, and more.

What is useEffect?

import { useEffect } from 'react';

function Component() {
  useEffect(() => {
    // Side effect code here
    console.log('Effect ran!');
  });
  
  return <div>Hello</div>;
}

useEffect runs after every render by default.

Basic Syntax

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

Three parts:

  1. Effect function - runs after render
  2. Cleanup function - runs before effect re-runs or component unmounts (optional)
  3. Dependency array - controls when effect runs (optional)

Dependency Array

Controls when the effect runs:

No dependency array - runs after every render:

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

Empty array - runs once on mount:

useEffect(() => {
  console.log('Runs ONCE after first render');
}, []);

With dependencies - runs when dependencies change:

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

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

Common Use Cases

1. Data Fetching

function UserProfile({ userId }) {
  const [user, setUser] = useState(null);
  const [loading, setLoading] = useState(true);
  
  useEffect(() => {
    setLoading(true);
    
    fetch(`/api/users/${userId}`)
      .then(response => response.json())
      .then(data => {
        setUser(data);
        setLoading(false);
      });
  }, [userId]);  // Re-fetch when userId changes
  
  if (loading) return <div>Loading...</div>;
  if (!user) return <div>User not found</div>;
  
  return <div>{user.name}</div>;
}

2. Subscriptions

function ChatRoom({ roomId }) {
  const [messages, setMessages] = useState([]);
  
  useEffect(() => {
    // Subscribe
    const subscription = chatAPI.subscribe(roomId, (message) => {
      setMessages(prev => [...prev, message]);
    });
    
    // Cleanup: unsubscribe when component unmounts or roomId changes
    return () => {
      subscription.unsubscribe();
    };
  }, [roomId]);
  
  return (
    <div>
      {messages.map(msg => <div key={msg.id}>{msg.text}</div>)}
    </div>
  );
}

3. Document Title

function PageTitle({ title }) {
  useEffect(() => {
    document.title = title;
  }, [title]);
  
  return <h1>{title}</h1>;
}

4. Timers

function Timer() {
  const [seconds, setSeconds] = useState(0);
  
  useEffect(() => {
    const interval = setInterval(() => {
      setSeconds(s => s + 1);
    }, 1000);
    
    // Cleanup: clear interval on unmount
    return () => clearInterval(interval);
  }, []);  // Empty array = set up once
  
  return <div>Seconds: {seconds}</div>;
}

5. localStorage Sync

function Settings() {
  const [theme, setTheme] = useState(() => {
    return localStorage.getItem('theme') || 'light';
  });
  
  useEffect(() => {
    localStorage.setItem('theme', theme);
  }, [theme]);
  
  return (
    <button onClick={() => setTheme(theme === 'light' ? 'dark' : 'light')}>
      Current theme: {theme}
    </button>
  );
}

Cleanup Function

Return a function to clean up side effects:

useEffect(() => {
  // Set up
  const subscription = api.subscribe();
  
  // Clean up
  return () => {
    subscription.unsubscribe();
  };
}, []);

When cleanup runs:

  • Before the effect runs again (when dependencies change)
  • When component unmounts

Example with lifecycle:

function Component({ id }) {
  useEffect(() => {
    console.log('Effect: Setting up for', id);
    
    return () => {
      console.log('Cleanup: Cleaning up for', id);
    };
  }, [id]);
  
  return <div>{id}</div>;
}

// Renders with id=1: "Effect: Setting up for 1"
// Re-renders with id=2: "Cleanup: Cleaning up for 1", then "Effect: Setting up for 2"
// Unmounts: "Cleanup: Cleaning up for 2"

Multiple useEffect Hooks

Separate concerns into different effects:

function UserDashboard({ userId }) {
  const [user, setUser] = useState(null);
  const [posts, setPosts] = useState([]);
  
  // Effect 1: Fetch user
  useEffect(() => {
    fetch(`/api/users/${userId}`)
      .then(r => r.json())
      .then(setUser);
  }, [userId]);
  
  // Effect 2: Fetch posts
  useEffect(() => {
    fetch(`/api/users/${userId}/posts`)
      .then(r => r.json())
      .then(setPosts);
  }, [userId]);
  
  // Effect 3: Update page title
  useEffect(() => {
    if (user) {
      document.title = `${user.name}'s Profile`;
    }
  }, [user]);
  
  return <div>...</div>;
}

Async in useEffect

Cannot make useEffect callback async directly:

// ❌ Wrong - useEffect callback can't be async
useEffect(async () => {
  const data = await fetchData();
}, []);

Define async function inside:

// ✅ Right
useEffect(() => {
  async function fetchData() {
    const response = await fetch('/api/data');
    const data = await response.json();
    setData(data);
  }
  
  fetchData();
}, []);

// ✅ Also right - IIFE
useEffect(() => {
  (async () => {
    const data = await fetchData();
    setData(data);
  })();
}, []);

Practical Examples

Example 1: Search with Debounce

function SearchBox() {
  const [query, setQuery] = useState('');
  const [results, setResults] = useState([]);
  
  useEffect(() => {
    if (!query) {
      setResults([]);
      return;
    }
    
    // Debounce: wait 500ms after user stops typing
    const timer = setTimeout(() => {
      fetch(`/api/search?q=${query}`)
        .then(r => r.json())
        .then(setResults);
    }, 500);
    
    // Cleanup: cancel previous timer if user types again
    return () => clearTimeout(timer);
  }, [query]);
  
  return (
    <div>
      <input 
        value={query}
        onChange={(e) => setQuery(e.target.value)}
        placeholder="Search..."
      />
      <ul>
        {results.map(item => (
          <li key={item.id}>{item.name}</li>
        ))}
      </ul>
    </div>
  );
}

Example 2: Window Event Listener

function WindowSize() {
  const [size, setSize] = useState({
    width: window.innerWidth,
    height: window.innerHeight
  });
  
  useEffect(() => {
    const handleResize = () => {
      setSize({
        width: window.innerWidth,
        height: window.innerHeight
      });
    };
    
    window.addEventListener('resize', handleResize);
    
    // Cleanup: remove listener
    return () => {
      window.removeEventListener('resize', handleResize);
    };
  }, []);  // Empty array - set up once
  
  return <div>Window: {size.width} x {size.height}</div>;
}

Example 3: Fetch with Loading & Error

function UserList() {
  const [users, setUsers] = useState([]);
  const [loading, setLoading] = useState(true);
  const [error, setError] = useState(null);
  
  useEffect(() => {
    setLoading(true);
    setError(null);
    
    fetch('/api/users')
      .then(response => {
        if (!response.ok) throw new Error('Failed to fetch');
        return response.json();
      })
      .then(data => {
        setUsers(data);
        setLoading(false);
      })
      .catch(err => {
        setError(err.message);
        setLoading(false);
      });
  }, []);
  
  if (loading) return <div>Loading...</div>;
  if (error) return <div>Error: {error}</div>;
  
  return (
    <ul>
      {users.map(user => (
        <li key={user.id}>{user.name}</li>
      ))}
    </ul>
  );
}

Example 4: Auto-save Form

function AutoSaveForm() {
  const [formData, setFormData] = useState({ title: '', content: '' });
  const [saveStatus, setSaveStatus] = useState('');
  
  useEffect(() => {
    // Don't save on initial render
    if (formData.title === '' && formData.content === '') return;
    
    setSaveStatus('Saving...');
    
    const timer = setTimeout(() => {
      // Simulate API call
      localStorage.setItem('draft', JSON.stringify(formData));
      setSaveStatus('Saved!');
      
      // Clear status after 2 seconds
      setTimeout(() => setSaveStatus(''), 2000);
    }, 1000);  // Wait 1 second after user stops typing
    
    return () => clearTimeout(timer);
  }, [formData]);
  
  return (
    <div>
      <input 
        value={formData.title}
        onChange={(e) => setFormData({ ...formData, title: e.target.value })}
        placeholder="Title"
      />
      <textarea 
        value={formData.content}
        onChange={(e) => setFormData({ ...formData, content: e.target.value })}
        placeholder="Content"
      />
      <div className="save-status">{saveStatus}</div>
    </div>
  );
}

Example 5: WebSocket Connection

function LiveFeed() {
  const [messages, setMessages] = useState([]);
  
  useEffect(() => {
    const ws = new WebSocket('wss://api.example.com/live');
    
    ws.onopen = () => {
      console.log('Connected');
    };
    
    ws.onmessage = (event) => {
      const message = JSON.parse(event.data);
      setMessages(prev => [message, ...prev].slice(0, 50));  // Keep last 50
    };
    
    ws.onerror = (error) => {
      console.error('WebSocket error:', error);
    };
    
    // Cleanup: close connection
    return () => {
      ws.close();
    };
  }, []);  // Connect once on mount
  
  return (
    <div>
      {messages.map((msg, i) => (
        <div key={i}>{msg.text}</div>
      ))}
    </div>
  );
}

Dependency Array Best Practices

1. Include all dependencies:

// ❌ Bad - missing dependency
useEffect(() => {
  console.log(count);
}, []);  // Should include count!

// ✅ Good
useEffect(() => {
  console.log(count);
}, [count]);

2. Use ESLint plugin:

npm install eslint-plugin-react-hooks

This catches missing dependencies automatically.

3. Be careful with objects/arrays:

// ❌ Bad - new object every render, infinite loop!
const config = { apiUrl: '/api' };
useEffect(() => {
  fetch(config.apiUrl);
}, [config]);  // New object reference every time!

// ✅ Good - primitive value
const apiUrl = '/api';
useEffect(() => {
  fetch(apiUrl);
}, [apiUrl]);

// ✅ Good - memoize object
const config = useMemo(() => ({ apiUrl: '/api' }), []);
useEffect(() => {
  fetch(config.apiUrl);
}, [config]);

Common Patterns

1. Conditional effect:

useEffect(() => {
  if (!shouldRun) return;
  
  // Effect code
}, [shouldRun, otherDeps]);

2. Race condition prevention:

useEffect(() => {
  let cancelled = false;
  
  fetch('/api/data')
    .then(r => r.json())
    .then(data => {
      if (!cancelled) {  // Only update if still mounted
        setData(data);
      }
    });
  
  return () => {
    cancelled = true;  // Mark as cancelled on cleanup
  };
}, []);

3. Combining with custom hooks:

function useFetch(url) {
  const [data, setData] = useState(null);
  const [loading, setLoading] = useState(true);
  
  useEffect(() => {
    setLoading(true);
    fetch(url)
      .then(r => r.json())
      .then(data => {
        setData(data);
        setLoading(false);
      });
  }, [url]);
  
  return { data, loading };
}

// Usage
function Component() {
  const { data, loading } = useFetch('/api/users');
  
  if (loading) return <div>Loading...</div>;
  return <div>{data.name}</div>;
}

Best Practices

1. Keep effects focused:

// Bad - doing too much in one effect
useEffect(() => {
  fetchUser();
  updateTitle();
  trackAnalytics();
  subscribeToUpdates();
}, []);

// Good - separate concerns
useEffect(() => { fetchUser(); }, []);
useEffect(() => { updateTitle(); }, [user]);
useEffect(() => { trackAnalytics(); }, [page]);
useEffect(() => { 
  const sub = subscribeToUpdates();
  return () => sub.unsubscribe();
}, []);

2. Always clean up:

// Good - cleanup timers
useEffect(() => {
  const timer = setTimeout(() => {}, 1000);
  return () => clearTimeout(timer);
}, []);

// Good - cleanup listeners
useEffect(() => {
  window.addEventListener('scroll', handleScroll);
  return () => window.removeEventListener('scroll', handleScroll);
}, []);

3. Don’t lie about dependencies:

// ❌ Bad - lying to ESLint
useEffect(() => {
  doSomething(value);
}, []);  // eslint-disable-line

// ✅ Good - honest dependencies
useEffect(() => {
  doSomething(value);
}, [value]);

Common Mistakes

1. Infinite loop:

// ❌ Causes infinite loop
const [count, setCount] = useState(0);
useEffect(() => {
  setCount(count + 1);  // Updates count, triggers effect again!
});

// ✅ Fixed with dependency array
useEffect(() => {
  setCount(count + 1);
}, []);  // Runs once

2. Missing cleanup:

// ❌ Memory leak
useEffect(() => {
  const interval = setInterval(() => {
    console.log('tick');
  }, 1000);
  // No cleanup!
}, []);

// ✅ Fixed
useEffect(() => {
  const interval = setInterval(() => {
    console.log('tick');
  }, 1000);
  return () => clearInterval(interval);
}, []);

3. Async callback:

// ❌ Wrong
useEffect(async () => {
  await fetch('/api/data');
}, []);

// ✅ Right
useEffect(() => {
  fetch('/api/data');
}, []);

// ✅ Right with async
useEffect(() => {
  async function loadData() {
    await fetch('/api/data');
  }
  loadData();
}, []);