You might still need a useEffect
A gentle introduction to useEffect, synchronization, cleanup, dependencies, referential stability, and related React hooks.
A gentle introduction to synchronization in React
useEffect is one of the first React hooks that starts to feel confusing. The name sounds simple, but the behavior gets strange when an effect runs too many times, doesn't run when you expect, or keeps doing work after a component has left the screen.
The clearest way to think about it is this:
useEffectis how your component talks to things that live outside React.
That "outside" includes the DOM, network requests, timers, subscriptions, browser APIs like window.innerWidth, and third-party libraries that want to attach to a real DOM node. Anything that isn't already managed by props and state. The effect's job is to set up a connection to that outside thing on the way in, and tear it down on the way out.
If you want to go deeper on when to use effects and when to avoid them, the React docs have a guide called You Might Not Need an Effect, and Alvin Sng has written about banning direct useEffect in production codebases. Both are worth reading once you've got the basics down. This article is the basics.
How useEffect works
useEffect takes two arguments: a callback function and a dependency array.
useEffect(() => {
// ...your code
}, [dep1, dep2]);
Three things determine when and how the callback runs.
The callback runs after the component renders
When React renders your component and updates the DOM, it then runs the callback. Anything inside the callback happens after the user can already see the result of the render. That's why effects are a safe place for side effects: they don't block the screen from updating.
The dependency array decides when to re-run the callback
When you provide a dependency array, React compares the values inside it against the previous render's values. If any of them changed, React runs the callback again. If none of them changed, React skips it.
There are three shapes the array can take, and each one means something different:
// 1. Specific dependencies: re-run when any of these change
useEffect(() => {
const connection = openChatRoom(roomId);
}, [roomId]);
// 2. Empty array: run once after the first render, never again
useEffect(() => {
console.log("mounted");
}, []);
// 3. No array at all: run after every render
useEffect(() => {
console.log("rendered");
});
Most of the time you want the first shape. The empty array is useful when the work doesn't depend on any changing value, like a one-time setup. The no-array form is rarely what you want, and the dependency linter will usually warn you against it.
The callback returns a cleanup function
The work an effect starts often needs to be stopped. A setInterval should be cleared. An event listener should be removed. A subscription should be cancelled. To handle that, the callback can return a function, and React will run that function before the next time the effect runs or when the component unmounts.
useEffect(() => {
const connection = openChatRoom(roomId); // setup
return () => {
connection.close(); // teardown
};
}, [roomId]);
When roomId changes, React closes the old connection before opening the new one. When the component leaves the screen, React closes the connection one last time. This is what most beginner tutorials describe as "mounting and unmounting," but the more useful framing is "open and close a connection."
That's the entire model. Setup runs after render. Teardown runs before the next setup or before unmount. The dependency array decides when to do another setup-teardown cycle.
When not to write an effect
Now that you know what an effect does, it's easier to spot the cases where one shouldn't be there at all. Two patterns come up constantly.
Deriving a value from props or state
If a value can be calculated from existing props or state, calculate it during render. Don't reach for an effect:
// Don't do this
function Greeting({ firstName, lastName }) {
const [fullName, setFullName] = useState("");
useEffect(() => {
setFullName(`${firstName} ${lastName}`);
}, [firstName, lastName]);
return <h1>Hello, {fullName}</h1>;
}
// Do this
function Greeting({ firstName, lastName }) {
const fullName = `${firstName} ${lastName}`;
return <h1>Hello, {fullName}</h1>;
}
The effect version forces React to render once with an empty fullName, run the effect, then render again with the real value. Two renders to do the work of one. There's also nothing outside React being synchronized here, so the effect doesn't earn its place.
Reacting to a user event
If something should happen because a user clicked a button, that logic belongs in the click handler. It doesn't belong in an effect that fires after the resulting state change:
// Don't do this
useEffect(() => {
if (product.isInCart) {
showNotification(`Added ${product.name} to cart`);
}
}, [product]);
// Do this
function handleAddToCart() {
addToCart(product);
showNotification(`Added ${product.name} to cart`);
}
The effect version fires the notification any time product changes, including on initial render if the product was already in the cart from a previous session. The handler version only fires when the user actually clicks. That's almost always what you want.
The general test is: "is this code talking to something outside React?" If no, it probably shouldn't be an effect.
Three things to pay attention to when writing effects
Once you're writing real effects, three things determine whether they behave correctly. The first is positive: getting cleanup right. The other two are traps that catch most beginners.
1. Clean up what you start
Most subtle effect bugs come from missing cleanup. Whatever your setup starts, your teardown should stop. The shape is the same across every external system, just applied to different APIs.
Window event listeners. The teardown removes the same function the setup added:
useEffect(() => {
function handleResize() {
setWidth(window.innerWidth);
}
window.addEventListener("resize", handleResize);
return () => window.removeEventListener("resize", handleResize);
}, []);
removeEventListener requires the same function reference that addEventListener got, which is why handleResize is named and reused.
Intervals. The teardown clears the interval:
useEffect(() => {
const id = setInterval(() => {
setSeconds((s) => s + 1);
}, 1000);
return () => clearInterval(id);
}, []);
Using the functional form setSeconds((s) => s + 1) makes sure the callback always sees the current value, not a stale one captured when the interval was created.
Subscriptions. Many APIs return an unsubscribe function, which you can return directly from the effect:
useEffect(() => {
return chatRoom.subscribe((message) => {
setMessages((messages) => [...messages, message]);
});
}, [chatRoom]);
Network requests. The teardown either marks the response as ignored or aborts the request:
useEffect(() => {
let ignore = false;
fetch(`/api/users/${userId}`)
.then((res) => res.json())
.then((data) => {
if (!ignore) setUser(data);
});
return () => {
ignore = true;
};
}, [userId]);
The ignore flag prevents a stale response from overwriting newer data when userId changes mid-request. One practical caveat: in production apps, libraries like TanStack Query and SWR, or framework data layers in Next.js and Remix, handle race conditions, caching, and loading states for you. Writing your own fetch effects is fine for learning and small projects, but it stops being the right call at scale.
2. Effects run twice in development
If you've used React 18 or later, you've probably seen an effect fire twice in development and wondered if something was broken. Nothing is broken. React's Strict Mode runs setup, then teardown, then setup again, on purpose.
The reason is that Strict Mode is testing whether your teardown actually works. If running setup-teardown-setup leaves your component in a broken state, the synchronization isn't symmetric, and it'll eventually break in production too when a dependency change legitimately causes React to teardown and reopen the connection.
A buggy effect makes this visible:
// Logs two visits in development
useEffect(() => {
fetch("/api/visit", { method: "POST" });
}, []);
The fix is to add a teardown that cancels the in-flight request, or to move the analytics call somewhere that running it twice doesn't matter. Don't disable Strict Mode. It's catching a real bug for you while it's still cheap to fix.
3. Referential stability
Once you start writing dependency arrays carefully, you'll run into a frustrating-feeling case. The effect keeps firing on every render even though nothing seems to have changed. The cause is almost always referential stability.
React compares dependencies with Object.is. For primitives like strings and numbers, that compares values. For objects, arrays, and functions, it compares references. Since the component function reruns on every render, anything you build inside the function body gets a fresh reference each time, even if the contents are identical:
function SearchResults({ query }) {
const options = { limit: 20, query };
useEffect(() => {
search(options);
}, [options]);
}
options is a new object on every render, so the effect runs on every render too. There are four reasonable ways out, and the right one depends on what the value is for.
Option 1: depend on the primitive. If the object is only used inside the effect, build it inside the effect and depend on the primitive that actually changes:
useEffect(() => {
search({ limit: 20, query });
}, [query]);
This is the answer most of the time. Reach for it first.
Option 2: memoize the object. If something else also needs the object, such as a child component or another hook, wrap it in useMemo so its identity stays stable until its real inputs change:
const options = useMemo(() => ({ limit: 20, query }), [query]);
Option 3: memoize a function passed across components. useCallback matters when a function's identity is observed by something else, typically a memoized child:
const runSearch = useCallback(() => {
search({ limit: 20, query });
}, [query]);
return <SearchButton onSearch={runSearch} />;
If the function only lives inside one effect in one component, you don't need useCallback at all; depend on query directly.
Option 4: a ref for a value that should not retrigger the effect. Sometimes the effect genuinely should not restart when a value changes, but the effect still needs to read the latest version. A ref gives you a stable container whose .current you can update without triggering re-renders:
const latestQueryRef = useRef(query);
useEffect(() => {
latestQueryRef.current = query;
}, [query]);
useEffect(() => {
const id = setInterval(() => {
search({ limit: 20, query: latestQueryRef.current });
}, 5000);
return () => clearInterval(id);
}, []);
This works, but it's the option to reach for last. Refs hide which values actually drive the effect's behavior, and they make the dependency lint rule unable to help you. As of React 19.2, there's a cleaner tool for this exact case, which is in the next section.
Related hooks that extend the model
Plain useEffect covers most synchronization needs, but four situations have their own purpose-built tools. Each one solves a specific gap, and each one fits the same connection metaphor we've been building on.
useEffectEvent: read a value without restarting the effect
A common pain point with the dependency array is that it forces a bad trade. Either you list everything the effect reads, which is correct but may restart the connection more than you want, or you omit some values, which is wrong and makes the linter complain. The classic example is a chat room that wants to show a notification with the current theme:
function ChatRoom({ roomId, theme }) {
useEffect(() => {
const connection = openChatRoom(roomId);
connection.on("connected", () => {
showNotification("Connected!", theme);
});
return () => connection.close();
}, [roomId, theme]); // Reconnects when theme changes
}
The connection only cares about roomId. The notification call cares about theme. Listing both as dependencies means changing the theme tears down the chat connection and opens a new one, which is wasteful and visibly wrong.
useEffectEvent, stable since React 19.2 in October 2025, lets you split the "event-like" piece out of the effect. The effect still reads the latest theme when the notification fires, but theme no longer defines the connection's identity:
import { useEffect, useEffectEvent } from "react";
function ChatRoom({ roomId, theme }) {
const onConnected = useEffectEvent(() => {
showNotification("Connected!", theme);
});
useEffect(() => {
const connection = openChatRoom(roomId);
connection.on("connected", onConnected);
return () => connection.close();
}, [roomId]); // Only reconnects when roomId changes
}
This replaces the "ref to read the latest value" trick from Option 4 in the previous section. The hook signals intent, this is an event-like callback fired from inside an effect, and the linter understands not to flag it as a missing dependency.
Ref callbacks with cleanup: when the connection is to a DOM node
Sometimes the thing being synchronized isn't your component, it's a specific DOM node. Imagine attaching a ResizeObserver to a <div> so you can react to its dimensions changing. The conventional approach used a useRef plus a useEffect:
function MeasuredBox() {
const ref = useRef(null);
useEffect(() => {
if (!ref.current) return;
const observer = new ResizeObserver(() => {
console.log(ref.current.getBoundingClientRect());
});
observer.observe(ref.current);
return () => observer.disconnect();
}, []);
return <div ref={ref}>...</div>;
}
This works, but the ResizeObserver is conceptually tied to the <div>, not to the component's lifetime. React 19 in December 2024 made ref callbacks, functions you pass directly to ref, able to return cleanup functions, which lets you express that relationship directly:
function MeasuredBox() {
return (
<div
ref={(node) => {
const observer = new ResizeObserver(() => {
console.log(node.getBoundingClientRect());
});
observer.observe(node);
return () => observer.disconnect();
}}
>
...
</div>
);
}
The setup runs when React attaches the ref, and the cleanup runs when the node is detached or replaced. There's no useRef, no useEffect, and no null check. The synchronization is colocated with the node it depends on.
The rule of thumb: if the thing being synchronized is "this DOM node," prefer a ref callback with cleanup. If it's "this component's lifetime in general," use useEffect.
useSyncExternalStore: subscribing to data that drives rendering
You can subscribe to an external data source with useEffect, and the cleanup pattern from earlier works fine:
function useOnlineStatus() {
const [isOnline, setIsOnline] = useState(true);
useEffect(() => {
function update() {
setIsOnline(navigator.onLine);
}
update();
window.addEventListener("online", update);
window.addEventListener("offline", update);
return () => {
window.removeEventListener("online", update);
window.removeEventListener("offline", update);
};
}, []);
return isOnline;
}
This is fine for most cases. But when the external data drives what you actually render, such as a Redux store, a Zustand store, navigator.onLine, or anything where the value affects the UI, React provides useSyncExternalStore, which handles concurrent rendering edge cases that useEffect cannot:
function subscribe(callback) {
window.addEventListener("online", callback);
window.addEventListener("offline", callback);
return () => {
window.removeEventListener("online", callback);
window.removeEventListener("offline", callback);
};
}
function useOnlineStatus() {
return useSyncExternalStore(
subscribe,
() => navigator.onLine, // current value on the client
() => true // current value on the server
);
}
The hook takes a subscribe function, same shape as your effect's setup, a function that returns the current value, and an optional function that returns the value during server rendering. You'll most often see this through libraries, since Zustand and React Redux both use it under the hood, but it's worth knowing about when you're writing your own subscription logic.
useLayoutEffect: when synchronization has to happen before paint
useEffect runs after the browser paints, which keeps the screen feeling responsive. Once in a while, you need synchronization that has to happen before paint so the user doesn't see a flicker. Positioning a tooltip based on its measured width is the canonical case: if you measure and adjust in useEffect, the user briefly sees the tooltip in the wrong place before it snaps into position.
useLayoutEffect has the same API as useEffect but runs synchronously before paint:
useLayoutEffect(() => {
const { width } = tooltipRef.current.getBoundingClientRect();
setLeft(triggerLeft - width / 2);
}, [triggerLeft]);
Use it only when you actually need it. It blocks the screen from updating until your effect finishes, so the responsiveness benefit of plain useEffect is gone. If you ever notice a one-frame flash between an effect's setup and what the user sees, that's the signal that the effect should probably be a useLayoutEffect.
A checklist before writing an effect
Before reaching for useEffect, ask:
- Am I synchronizing with something outside React, or am I deriving a value from props and state? If it's the latter, calculate during render instead.
- What is the external system, and what defines the identity of the connection to it?
- What values does that identity depend on? Are they all in the dependency array?
- Does my teardown actually undo what my setup did?
- Will this still behave correctly under Strict Mode's setup-teardown-setup pattern in development?
- Is the connection tied to a specific DOM node? A ref callback with cleanup might be a better fit.
- Is the effect reading a value that shouldn't restart it?
useEffectEventis built for that.
The rules underneath these questions are simple. If your effect starts something, stop it. If it reads a value that can change, list that value as a dependency unless you have a deliberate reason not to. If you're only moving React values around inside React, you don't need an effect at all.
useEffect isn't an arbitrary hook with a weird name and surprising behavior. It's React's interface to the world outside it. Once you start treating it that way, the rest of the rules write themselves.
Further reading
- Synchronizing with Effects, official React documentation
- You Might Not Need an Effect, the canonical "don't reach for
useEffectfirst" guide - useEffectEvent reference, for the new hook stabilized in React 19.2
- useSyncExternalStore reference, for store subscriptions
- Alvin Sng on banning direct
useEffect, an opinionated take from a production codebase
comments