useEffect: My Mental Model
useEffect runs code after a render, in response to something changing (or once on mount).
The mental model: "synchronize this side effect with this state."
useEffect(setup, dependencies?)
The three forms
// 1. Run once on mount (and cleanup on unmount)
useEffect(() => {
const sub = subscribe();
return () => sub.unsubscribe();
}, []);
// 2. Run when specific values change
useEffect(() => {
fetchData(userId);
}, [userId]);
// 3. Run after every render (almost never what you want)
useEffect(() => {
doSomething();
});
When I actually use it
- On mount setup: connect to a WebSocket, subscribe to an event, initialize a third-party library
- Sync with external state: re-fetch data when a filter/ID changes, update the document title
- Cleanup: unsubscribe, clear timers, disconnect
Common mistakes I've made
Infinite loop: forgot to add a stable dependency or included an object/function that recreates every render.
// Bad: `options` is a new object every render
useEffect(() => { fetchData(options) }, [options]);
// Fix: destructure the primitives you actually need
useEffect(() => { fetchData(filter, page) }, [filter, page]);
Missing cleanup: subscriptions and timers that live past the component's unmount.
useEffect(() => {
const timer = setInterval(tick, 1000);
return () => clearInterval(timer); // always clean up
}, []);
Running fetch directly without aborting:
useEffect(() => {
const controller = new AbortController();
fetch(url, { signal: controller.signal }).then(...)
return () => controller.abort();
}, [url]);
My rule
If I'm tempted to write useEffect to sync two pieces of React state with each other — that's usually a sign I need to restructure. Derive, don't sync.
Real example from production:
function ChatRoom({ roomId }) {
const [serverUrl, setServerUrl] = useState('https://localhost:1234');
useEffect(() => {
const connection = createConnection(serverUrl, roomId);
connection.connect();
return () => connection.disconnect();
}, [serverUrl, roomId]);
}