How Does useEffect Actually Work?
If you've worked with React for a while, you've probably written something like this: useEffect(() => { fetchUser(); }, []); It looks simple. But then the questions start: - Why does useEffect run twice in development? - Why does it sometimes run again when I didn't expect it? - What exactly does the dependency array do? - Why does adding a dependency suddenly create an infinite loop? - Why do we need a cleanup function? - And perhaps the biggest question: when should I use useEffect at all? Understanding useEffect isn't really about memorizing its syntax. It's about understanding when React synchronizes your component with something outside of React. What is useEffect actually for? The simplest mental model is: useEffect lets your component synchronize with an external system. An external system could be: - A network connection - A WebSocket - A browser API - A timer - A third-party library - An event listener - A subscription For example, connecting to a chat server: useEffect(() => { const connection = createConnection(roomId); connection.connect(); return () => { connection.disconnect(); }; }, [roomId]); The component isn't just calculating what should appear on the screen. It's synchronizing something outside React with the current roomId . That's the important idea. React's own documentation describes Effects as an "escape hatch" for synchronizing with external systems. If you're not interacting with something external, you may not need an Effect at all. ([React][1]) First: Understand Render vs Effect This is where useEffect starts making sense. Imagine: function Profile({ userId }) { const [user, setUser] = useState(null); useEffect(() => { fetch(/api/users/${userId}) .then(res => res.json()) .then(setUser); }, [userId]); return {user?.name} ; } When userId changes, React roughly goes through: Props / State change โ Render โ React commits โ Effect runs โ External system The important distinction is: Rendering calculates the UI. An Effect synchronizes something outside that rendering process. Effects run after a commit when their dependencies require synchronization. ([React][1]) The Dependency Array Is Not a "Run When I Want" List Consider: useEffect(() => { console.log("Hello"); }); There is no dependency array. That means the Effect can run after every commit. Now: useEffect(() => { console.log("Hello"); }, []); The Effect has no reactive dependencies, so it doesn't re-run when props or state change. And: useEffect(() => { console.log(userId); }, [userId]); Now React compares userId with its previous value. Conceptually: Previous userId โ Compare โ Current userId โ Changed? / \ No Yes โ โ Skip Cleanup โ Setup React compares dependency values using Object.is . ([React][1]) You Don't Actually Choose Your Dependencies This is one of the most important things to understand. Suppose you write: useEffect(() => { console.log(userId); }, []); It may look like you're saying: "Run this only once." But your Effect is reading userId , which is a reactive value. So the dependency should generally be: useEffect(() => { console.log(userId); }, [userId]); The dependency list isn't supposed to be a list of values you personally want React to watch. It's determined by the reactive values your Effect uses. That's also why suppressing the exhaustive-deps lint rule can hide real bugs rather than solve them. ([React][1]) Cleanup: The Other Half of an Effect Consider a WebSocket connection: useEffect(() => { const socket = connect(roomId); return () => { socket.disconnect(); }; }, [roomId]); Why do we return a function? Because the Effect has two sides: SETUP โ Connect to room โ Use the connection โ CLEANUP โ Disconnect When roomId changes, React doesn't simply run the new setup. It first cleans up the old Effect: Old Effect โ Cleanup โ New Effect โ Setup And when the component is removed, React runs the cleanup one final time. ([React][1]) This makes Effects much easier to reason about. Instead of thinking: "What should happen when my component mounts?" Think: "What external system am I synchronizing with, and how do I start and stop that synchronization?" Why Does useEffect Run Twice? This confuses almost everyone at some point. You write: useEffect(() => { console.log("Effect"); }, []); And in development you see: Effect Effect It can look like React is broken. It's not. If your application is wrapped in StrictMode , React intentionally runs an extra development-only setup โ cleanup โ setup cycle for Effects. ([React][2]) Conceptually: Development + StrictMode Setup โ Cleanup โ Setup The purpose is to expose Effects that don't clean up correctly. For example, this is problematic: useEffect(() => { window.addEventListener("resize", handleResize); }, []); There is no cleanup. A better version: useEffect(() => { window.addEventListener("resize", handleResize); return () => { window.removeEventListener("resize", handleResize); }; }, []); Strict Mode is essentially asking: "If I start and stop this Effect immediately, does your code still behave correctly?" That's a very useful development check. The Infinite Loop Problem One of the most common useEffect mistakes looks like this: const [count, setCount] = useState(0); useEffect(() => { setCount(count + 1); }, [count]); What's happening? Effect runs โ setCount() โ State changes โ Component renders again โ count changed โ Effect runs again โ setCount() โ ... You've created a loop. The important lesson isn't: "Never update state inside useEffect ." State updates can be completely valid inside an Effect. The question is: Why does this Effect need to update state, and does that state change one of its dependencies? If the Effect isn't synchronizing with an external system, you may be using an Effect where ordinary React data flow would be simpler. ([React][1]) Be Careful With Objects and Functions Here's another subtle problem. function ChatRoom({ roomId }) { const options = { roomId, serverUrl: "https://example.com" }; useEffect(() => { connect(options); }, [options]); } options is created during every render. So even if the contents look identical: Render 1 โ options object A Render 2 โ options object B Render 3 โ options object C They're different object references. That can cause the Effect to re-run unnecessarily. A common improvement is to create the object inside the Effect: useEffect(() => { const options = { roomId, serverUrl: "https://example.com" }; connect(options); }, [roomId]); The same issue can happen with functions created during rendering. This is why blindly adding useCallback or useMemo isn't always the answer. First understand why the dependency changes. ([React][1]) useEffect Isn't Your Event Handler This distinction is extremely useful. An event handler responds to an interaction: function handleClick() { saveDocument(); } The user clicked something. An Effect responds to synchronization caused by rendering: useEffect(() => { connectToRoom(roomId); return () => disconnectFromRoom(roomId); }, [roomId]); The room ID changed, so the external connection needs to synchronize with it. A useful mental model: Event Handler โ User interaction โ Do something Effect โ Rendered state changed โ Synchronize with external system Effects are not meant to become a second event system for your application. A Better Way to Think About useEffect Instead of asking: "When does my component mount?" Ask: "When does this synchronization need to start, and when does it need to stop?" For example: WebSocket roomId changes โ Disconnect old room โ Connect new room Timer Component starts โ Start timer โ Component stops โ Clear timer Event listener Setup listener โ Component remains active โ Remove listener Subscription Subscribe โ Receive updates โ Unsubscribe This mental model is much more powerful than memorizing: useEffect(() => {}, []); What About Fetching Data? You can fetch data inside an Effect: useEffect(() => { fetch(/api/users/${userId}) .then(res => res.json()) .then(setUser); }, [userId]); But this doesn't mean: "Every API call should use useEffect ." Modern React applications often use framework-level data fetching or dedicated data-fetching libraries because they can handle caching, deduplication, loading states, and server rendering more effectively. So before writing: useEffect(() => { fetch(...); }, []); ask whether your framework already provides a better place to fetch that data. The useEffect Checklist Before adding an Effect, ask: 1. Am I synchronizing with something outside React? If no, you may not need an Effect. 2. What starts the synchronization? For example: roomId changes 3. What stops it? For example: disconnect() 4. Which reactive values does the Effect read? Those generally belong in the dependency list. 5. Can the Effect run more than once safely? It should. 6. Does cleanup undo the setup? If you connect, disconnect. If you subscribe, unsubscribe. If you add a listener, remove it. The Mental Model I Wish I Knew Earlier useEffect isn't: "Run this code after the component renders." That's incomplete. A better mental model is: useEffect is a synchronization mechanism between React and the outside world. The Effect has a lifecycle: Reactive Values โ Render โ Commit โ Effect Setup โ External System โ Dependency Changes? / \ No Yes โ โ Continue Cleanup โ Setup Once you understand this, many confusing useEffect behaviors become much easier to explain. Final Takeaway useEffect is one of those React APIs that looks tiny but has a surprisingly deep mental model. The syntax is easy: useEffect(() => { // synchronize return () => { // clean up }; }, [dependencies]); The difficult part is deciding whether you need it in the first place. When you do need it, think in terms of: Setup โ Synchronize โ Cleanup Not: Mount โ Do something โ Hope it doesn't run again. And when React runs your Effect more than once in development, don't immediately try to stop it. Instead, ask: "Is my Effect written so that setup and cleanup can safely happen more than once?" If the answer i
Comments
No comments yet. Start the discussion.