Appearance
Effects and lifecycle (useEffect will humble you)
onMountedmaps cleanly.watchmaps… sort of. Dependency arrays are where people invent new bug species.
Mental map
| Vue | React |
|---|---|
onMounted(() => …) | useEffect(() => { … }, []) |
onUnmounted(() => …) | return a cleanup from useEffect |
watch(source, cb) | useEffect(() => { … }, [source]) |
watchEffect | useEffect 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
idchanges 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,
-- ł.