Skip to content

Effects and lifecycle (useEffect will humble you) ​

onMounted maps cleanly. watch maps… sort of. Dependency arrays are where people invent new bug species.

Mental map ​

VueReact
onMounted(() => …)useEffect(() => { … }, [])
onUnmounted(() => …)return a cleanup from useEffect
watch(source, cb)useEffect(() => { … }, [source])
watchEffectuseEffect without thinking hard (dangerous)

Mount / unmount ​

tsx
import { useEffect } from 'react'

function Widget() {
  useEffect(() => {
    console.log('mounted')

    return () => {
      console.log('cleanup / unmount')
    }
  }, [])

  return <div>Widget</div>
}

Comments for the code above:

  • Empty deps [] ≈ run after mount, cleanup on unmount
  • In StrictMode (dev), React mounts → cleans up → mounts again on purpose. Your effect may log twice. That's normal 😅

Syncing with something outside React ​

Effects are for external systems: network (carefully), subscriptions, timers, imperative DOM libraries, document.title, etc.

tsx
function PageTitle({ title }: { title: string }) {
  useEffect(() => {
    document.title = title
  }, [title])

  return null
}

As you can see - when title changes, we update the outside world. Deps list says what we read.

The gotcha: "when X changes, set Y" ​

tsx
// usually a smell
useEffect(() => {
  setFullName(`${first} ${last}`)
}, [first, last])

Which now DOESN'T need an effect - just:

tsx
const fullName = `${first} ${last}`

If you're mirroring props into state "just in case", stop and ask if you actually need local state.

Fetching in useEffect (it works, it's not glamorous) ​

tsx
function User({ id }: { id: string }) {
  const [user, setUser] = useState<User | null>(null)
  const [error, setError] = useState<string | null>(null)

  useEffect(() => {
    const controller = new AbortController()

    async function load() {
      try {
        const res = await fetch(`/api/users/${id}`, { signal: controller.signal })
        if (!res.ok) throw new Error('Nope')
        setUser(await res.json())
      } catch (e) {
        if (e instanceof DOMException && e.name === 'AbortError') return
        setError('Failed to load')
      }
    }

    load()
    return () => controller.abort()
  }, [id])

  if (error) return <p>{error}</p>
  if (!user) return <p>Loading…</p>
  return <p>{user.name}</p>
}

Comments for the code above:

  • Abort on cleanup - race conditions when id changes fast
  • We'll replace a lot of this later with TanStack Query
  • Still useful to understand the raw pattern

Dependency arrays - the contract ​

React compares deps with Object.is. If anything in the list changed, re-run the effect.

Rules of thumb I've landed on:

  • Include everything from the component scope that the effect reads
  • Don't lie to the linter (eslint-plugin-react-hooks) - it is usually right
  • Unstable object/function deps cause loops - stabilize or move them

When NOT to use useEffect ​

  • Transforming data for render → compute during render
  • Handling a user event → put it in the event handler
  • Resetting state when a prop changes → often key={id} on the component is cleaner

Next: lists, keys, and conditionals - short chapter, high leverage.

Best,

-- ł.