Post on: 2024-12-10Last edited: 2026-7-21Words 757Read Time 4 min

useEffect is one of those React hooks that looks convenient until it quietly turns a component into something nobody wants to touch. I have written enough projects that were beaten up by messy effects to become a little cautious. This post is about when I use useEffect, when I try not to use it, and how I clean things up when an effect is unavoidable.

What A Side Effect Is

In React, a side effect is anything that happens outside the pure rendering process. For example:

  • Calling an API
  • Mutating the DOM directly
  • Subscribing to an external data source
  • Setting a timer or event listener
    These things are not rendering itself, but they can affect what the user eventually sees. React does not automatically know when they happen, so useEffect gives you an escape hatch.

What useEffect Looks Like

// Fetch data
useEffect(() => {
  const fetchUserData = async () => {
    const response = await fetch('/api/user')
    const data = await response.json()
    setUser(data)
  }

  fetchUserData()
}, [])  // Empty dependency array: run once on mount

// Mutate the DOM
useEffect(() => {
  const element = document.getElementById('my-element')
  element.style.opacity = '1'

  return () => {
    element.style.opacity = '0'
  }
}, [])

// Subscribe to an event
useEffect(() => {
  const handleResize = () => {
    setWindowSize({
      width: window.innerWidth,
      height: window.innerHeight,
    })
  }

  window.addEventListener('resize', handleResize)
  return () => window.removeEventListener('resize', handleResize)
}, [])

Declarative setup, cleanup functions, dependency tracking - it all looks nice. The trouble starts when one component has five or six effects, each watching a different dependency.

The Version That Hurts

I have written code like this before. Looking back, I want to stop my past self.

// Bad example
function UserDashboard() {
  const [user, setUser] = useState(null)
  const [posts, setPosts] = useState([])
  const [notifications, setNotifications] = useState([])

  // Fetch user
  useEffect(() => {
    fetchUserProfile().then(setUser)
  }, [])

  // Fetch posts
  useEffect(() => {
    if (user) {
      fetchUserPosts(user.id).then(setPosts)
    }
  }, [user])

  // Fetch settings
  useEffect(() => {
    if (user) {
      fetchUserSettings(user.id)
    }
  }, [user])

  // Poll notifications
  useEffect(() => {
    if (!user) return

    const timer = setInterval(() => {
      checkNotifications(user.id).then(setNotifications)
    }, 5000)

    return () => clearInterval(timer)
  }, [user])
}

There are four effects scattered through the component. Each one watches its own dependency. It is hard to see what runs first, what triggers what, and which update causes another render. Add some useCallback and useMemo, and the component enters the "do not touch this" zone.

How I Write It Now

My rule is simple: not every side effect needs to live directly inside the component. If I can move it out, I move it out. The usual options are:

  1. Put related side effects into a custom hook, so the component calls one line.
  2. Use React Query, SWR, or server components for data fetching instead of writing fetch logic in effects.
  3. When an effect is really needed, pay close attention to the dependency array and cleanup function.
    Here is a cleaner version of the dashboard example:
// Better: a custom hook
function useUserData(userId) {
  const [user, setUser] = useState(null)
  const [loading, setLoading] = useState(true)
  const [error, setError] = useState(null)

  useEffect(() => {
    let mounted = true

    const fetchUser = async () => {
      try {
        setLoading(true)
        const data = await fetchUserProfile(userId)
        if (mounted) {
          setUser(data)
          setError(null)
        }
      } catch (err) {
        if (mounted) {
          setError(err)
          setUser(null)
        }
      } finally {
        if (mounted) {
          setLoading(false)
        }
      }
    }

    fetchUser()

    return () => {
      mounted = false
    }
  }, [userId])

  return { user, loading, error }
}

function UserDashboard({ userId }) {
  const { user, loading, error } = useUserData(userId)

  if (loading) return <LoadingSpinner />
  if (error) return <ErrorMessage error={error} />
  if (!user) return null

  return (
    <div>
      <h1>Welcome, {user.name}!</h1>
      {/* other UI */}
    </div>
  )
}

The mounted flag prevents state updates after the component unmounts. Without some form of cleanup, the red warning in the console will eventually find you.

Bottom Line

useEffect is not the problem. Using it for everything is the problem. Before I write a new effect, I now ask: "Does this truly need to happen after render?" If the answer is "not really," it probably belongs outside the component or inside a dedicated hook.

References

Follow updates

Use open feeds for new articles, research, and important updates.


Loading...
Auto-Scrolling AI Chats: How to Read User Intent

Auto-Scrolling AI Chats: How to Read User Intent

Auto-scroll in an AI chat is not just scrollTop. It needs intent signals, a follow/free state machine, handling for programmatic scroll events, virtualization, and mobile edge cases.


How AI Coding Assistants Actually Improve Developer Efficiency

How AI Coding Assistants Actually Improve Developer Efficiency

Based on hands-on use of Copilot, Cursor, and Claude Code, this post separates where AI assistants help most, such as search, boilerplate, and error diagnosis, from where they still struggle, such as architecture and edge cases.