useEffect is the standard place to synchronize a component with an external system: subscriptions, timers, DOM mutations, analytics, and network requests. Three dependency-array shapes control its lifecycle. With [], the effect runs once on mount and its cleanup runs on unmount. With [dep], it re-runs whenever the dep changes, and cleanup runs before each re-run as well as on unmount. With no array at all, it runs after every render — almost never what you want. The cleanup function must reverse whatever the effect did: clear timers, remove listeners, abort fetches, disconnect observers. Forgetting cleanup is the single most common source of memory leaks and double-fire bugs, especially under StrictMode. Effects run in declaration order on mount; cleanup runs in reverse order on unmount, which is why later effects can rely on earlier ones still being alive.
Timing matters. useEffect runs asynchronously after the browser paints, so users may briefly see stale UI before the effect runs. When you need to read layout — for example, getBoundingClientRect — and apply changes synchronously to avoid flicker, use useLayoutEffect; it blocks the paint. Heavy work in useLayoutEffect delays rendering, so use it sparingly and only for measurement-and-update patterns. Effects do not participate in Suspense: useEffect cannot throw a Promise and be caught by a boundary, because by the time it runs the paint has already happened. For data that needs Suspense integration, use a data library, Server Components, or the use hook in render.
Two recurring footguns deserve special attention. First, the effect callback cannot be async, because returning a Promise breaks the contract that expects either a cleanup function or void. The fix is to define an inner async function and invoke it, or to use AbortController for cancellable work and call controller.abort() in cleanup. Second, an effect that updates state based on the same state it reads will loop unless guarded — track a request id in a ref and ignore stale responses, or set an exit condition in a ref. The same race-condition pattern shows up with two effects in flight writing to the same state: the slower one overwrites the newer result, so track a monotonically increasing id and discard responses whose id is no longer the latest. Whenever you find yourself reaching for useEffect, ask the "you might not need an effect" question first: derived values, event handlers, and expensive computations often belong outside it, with the effect reserved for genuine synchronization with an external system.