Most useEffect calls in modern React codebases were written before Server Components, route loaders, and query libraries existed. The "You Might Not Need an Effect" checklist still works, but it no longer asks the right first question. This guide walks through the audit that replaces it.
Open any React project that's been alive for more than a year and you'll find the same opening stanza in component after component: const [data, setData] = useState(null); followed by a useEffect that fetches something, guards against race conditions (if you're lucky), and sets loading flags. None of it was wrong when it was written. Almost none of it should still be there.
The React docs page React checklist is a useful reference for understanding common React patterns. It's good. It asks "do I need this effect?" and walks through the cases where the answer is no. But that question assumes a client-only Single Page Application (SPA) where every component runs in the browser and every fetch happens after mount. That assumption broke when these frameworks went stable. The better first question in 2026 is "which layer does this work belong on?"
This guide walks through the audit process. By the end, you'll have a repeatable reflex for placing work on the right layer, a concrete workflow for grepping an existing codebase, and a short, honest list of what useEffect is actually still for.
In Brief:
- Modern React architectures have changed where many side effects are handled today.
- Most data-fetching effects belong on a layer above the component: a Server Component, a route loader, or a query hook.
- Inside the client, derived values, event handlers, and the
keyprop replace a second wave of effects with zero new dependencies. - A short list of cases—such as WebSockets, browser APIs, and third-party widgets—is what
useEffectis actually for.
Why the "Do I Need an Effect?" Rules Don't Fit Modern React
The guidance is largely illustrated with client-side components and post-mount data fetching examples. That was the reality for most teams writing SPAs in the hooks era. It isn't the reality now.
That assumption broke when these frameworks went stable. Server Components run before the client ever sees the page. Route loaders run on navigation, not after mount. Query libraries like TanStack Query and SWR cache across mounts and deduplicate requests automatically. Each of these absorbed a chunk of what useEffect used to do.
A checklist that only compares "effect vs. render" misses three or four other places the work could live entirely. It catches derived-state anti-patterns and event-handler misplacements, which are genuinely useful, but it never asks whether the work belongs on the server, in a loader, or in a query cache.
The cost of getting the layer wrong is bigger than people realize. Data fetching in a useEffect can lead to an extra loading render the user experiences through a spinner and can introduce race conditions that routing or query frameworks often help prevent. As the React documentation explains, much of the confusion comes from thinking about effects from the component's perspective (like a lifecycle) instead of the effect's perspective (what the effect does). The layer question reframes that entirely.
Sign up for the Logbook, Strapi's Monthly newsletter
The New First Question: Which Layer Should This Work Happen On?
Replace "do I need an effect?" with "which layer of my stack owns this work?" Here are the six layers, in order of preference:
- Server Component. If the data is part of the page's initial content, fetch it in an async Server Component. No state, no loading flag, no cleanup. The fetch is a top-level
await. - Route Loader. If the data is tied to a navigation event, move it to a loader (async
page.tsxcomponent in App Router,loaderin Remix,loaderin TanStack Start). The data arrives before the component renders: zero loading flashes for initial route data. - Cached Query Hook. If the data needs to stay fresh on the client, polling, revalidation on focus, mutations that update the cache, reach for TanStack Query, SWR, Apollo Client, or RTK Query. These libraries handle query defaults without a single
useEffect. - Derived Value Calculated During Render. If it's a function of props or state, it's a value, not stored state. Calculate it inline. No hook required.
- Event Handler. If the work happens because a user clicked, submitted, or dragged something, it belongs in the handler that already has the event context.
useEffect. Last resort. Real external systems only: WebSockets, browser observers, third-party widgets that own their own DOM subtree.
The audit rule: for every effect, work down from the top until you find the first layer that fits. Stop there. Each layer up the stack is cheaper than the one below it, at runtime and in cognitive load.
The Server-Side Layers Absorb Most Data-Fetching Effects
Most legacy useEffect calls are doing data fetching. Most of those don't belong on the client at all anymore.
Move It to a Server Component When the Data Is Part of the Page
The fetch becomes a top-level await in an async component. No useState, no loading flag, no useEffect, no cleanup function, no race condition guard. As the React docs put it: React docs, it's common to fetch dynamic data on the client in an Effect. That line frames effect-based fetching as the common client-side pattern that Server Components offer an alternative to.
Here's the before and after. First, the useEffect version:
function ProductList() {
const [products, setProducts] = useState(null);
useEffect(() => {
let ignore = false;
fetch('/api/products')
.then(r => r.json())
.then(data => { if (!ignore) setProducts(data); });
return () => { ignore = true; };
}, []);
if (!products) return <Spinner />;
return <ul>{products.map(p => <li key={p.id}>{p.name}</li>)}</ul>;
}Now the Server Component doing the same thing:
export default async function ProductList() {
const data = await fetch('https://api.example.com/products');
const products = await data.json();
return <ul>{products.map(p => <li key={p.id}>{p.name}</li>)}</ul>;
}Five lines. Race conditions are structurally impossible: the component runs once per server request, with no component lifecycle, no unmounting, and no concurrent in-flight requests for the same component instance.
The trade-off: the data is fixed at render time. If it has to update reactively in the browser (polling, real-time updates, user-triggered mutations), this is the wrong layer. Keep reading.
Move It to a Route Loader When the Data Is Tied to a Navigation Event
Route loaders run when the user navigates to the route, not after the component mounts. That's earlier in the lifecycle, with framework-level error and pending boundaries already in place.
The difference across frameworks is mostly syntactic. Here's the same fetch as a loader in each:
// Next.js App Router: async page component
export default async function Page({ params }: { params: Promise<{ id: string }> }) {
const { id } = await params;
const post = await fetch(`/api/posts/${id}`).then(r => r.json());
return <article>{post.content}</article>;
}Client-Side Patterns That Still Don't Need useEffect
Some work has to stay in the browser. That doesn't mean it has to live in an effect. Three patterns absorb almost everything that's left.
Calculate It During Render Instead of Syncing It with an Effect
Filtered lists, sorted lists, formatted strings, computed totals. If it's a function of props or state, it's a value, not stored state. The most common useEffect misuse causes two renders per update: the first render shows stale derived data, the effect fires after paint, calls setState, and triggers a second render.
// ❌ Two renders: stale value shown, then corrected after effect fires
function TodoList({ todos, filter }) {
const [visibleTodos, setVisibleTodos] = useState([]);
useEffect(() => {
setVisibleTodos(getFilteredTodos(todos, filter));
}, [todos, filter]);
}
// ✅ One render: always correct, zero extra passes
function TodoList({ todos, filter }) {
const visibleTodos = getFilteredTodos(todos, filter);
}For the rare expensive case, useMemo caches the result without involving useEffect:
const visibleTodos = useMemo(
() => getFilteredTodos(todos, filter),
[todos, filter]
);With the React Compiler v1.0, stable since October 2025, auto-memoization happens automatically for new code. The compiler auto-memoizes values and functions, reducing the need for manual useMemo calls. In existing codebases migrating to the Compiler, leave existing useMemo in place and test before removing.
Run It in the Event Handler That Caused It
The handler already has the event context and runs synchronously. Routing through state and an effect costs a render and loses the original event.
// ❌ Flag → effect → action → reset flag (two wasted renders)
function ProductPage({ product }) {
const [shouldBuy, setShouldBuy] = useState(false);
useEffect(() => {
if (shouldBuy) {
fetch('/api/buy', { method: 'POST', body: product.id });
showNotification('Purchase complete!');
setShouldBuy(false);
}
}, [shouldBuy, product]);
return <button onClick={() => setShouldBuy(true)}>Buy</button>;
}
// ✅ Handler does the work directly: zero state changes needed
function ProductPage({ product }) {
function handleBuyClick() {
fetch('/api/buy', { method: 'POST', body: product.id });
showNotification('Purchase complete!');
}
return <button onClick={handleBuyClick}>Buy</button>;
}This covers form submissions, button clicks, drag-end handlers, and mutations. The useMutation call (or its equivalent) belongs in the click handler, not in an effect watching a submitted flag. As the React docs note: "Event handlers are always triggered 'manually'... Effects, on the other hand, are 'automatic'."
Reset State with Key, Not with an Effect That Watches a Prop
The useEffect(() => setX(initial), [propThatChanged]) pattern is the most common state-reset anti-pattern in production codebases. It causes a double render and a visible stale-state flash.
// ❌ Effect watches prop, resets state: stale flash between renders
function ProfileForm({ userId }) {
const [comment, setComment] = useState('');
useEffect(() => { setComment(''); }, [userId]);
return <textarea value={comment} onChange={e => setComment(e.target.value)} />;
}
// ✅ key prop forces a fresh instance: state resets for free
<ProfileForm key={selectedUser.id} userId={selectedUser.id} />When key changes, React unmounts the existing instance, destroying all state, and mounts a completely fresh one. No effect, no second render, no stale state flash, including state in children you forgot existed.
The trade-off worth naming: full remount, so heavy children re-initialize. Most of the time that's fine. When it isn't, you'll see the flash.
When useEffect Is Still the Right Tool
After the audit, what's left is real. These effects earn their dependency array. The React docs define the boundary clearly: "If you're not trying to synchronize with some external system, you probably don't need an Effect."
WebSocket and Real-Time Subscriptions
Open on mount, close on unmount, reconnect when the room ID changes. The dependency array was designed for exactly this shape:
function ChatRoom({ roomId }) {
const [serverUrl, setServerUrl] = useState('wss://localhost:1234');
useEffect(() => {
const connection = createConnection(serverUrl, roomId);
connection.connect();
return () => {
connection.disconnect();
};
}, [serverUrl, roomId]);
}The setup (connect) and cleanup (disconnect) mirror each other exactly. When roomId or serverUrl changes, React disconnects from the old room and connects to the new one. Under <StrictMode>, React runs an extra setup+cleanup cycle in development. If your WebSocket cleanup isn't idempotent, you'll find out here rather than in production.
Browser APIs That React Doesn't Manage
IntersectionObserver, ResizeObserver, media query listeners, navigator.onLine, these are external systems React doesn't control.
For global browser state with a synchronous snapshot (like navigator.onLine or matchMedia), reach for useSyncExternalStore first. It prevents tearing during concurrent renders, and React recommends useSyncExternalStore for subscribing to external stores rather than relying on an effect-based pattern.
Fall back to useEffect when the API doesn't fit that shape, specifically Observer APIs like IntersectionObserver and ResizeObserver. These deliver data asynchronously via callback and require a mounted DOM element (ref.current), which doesn't exist at getSnapshot execution time:
function useIntersectionObserver(options = {}) {
const ref = useRef(null);
const [isIntersecting, setIsIntersecting] = useState(false);
useEffect(() => {
const element = ref.current;
if (!element) return;
const observer = new IntersectionObserver(
(entries) => {
const [entry] = entries;
setIsIntersecting(entry.isIntersecting);
},
{
root: options.root ?? null,
rootMargin: options.rootMargin ?? '0px',
threshold: options.threshold ?? 0,
}
);
observer.observe(element);
return () => { observer.disconnect(); };
}, [options.root, options.rootMargin, options.threshold]);
return { ref, isIntersecting };
}Third-Party Widgets and DOM Measurements
Map SDKs, rich-text editors, and charting libraries that own their own DOM subtree need useEffect to synchronize React state to the external widget. The React docs recommend splitting effects that synchronize several independent things into separate effects, one per independent synchronization process.
For DOM measurements taken after layout (tooltip positioning, scroll restoration, anything that affects what the user sees on the first frame), use useLayoutEffect, not useEffect. It fires before the browser repaints, so the user never sees the unadjusted frame:
function Tooltip() {
const ref = useRef(null);
const [tooltipHeight, setTooltipHeight] = useState(0);
useLayoutEffect(() => {
const { height } = ref.current.getBoundingClientRect();
setTooltipHeight(height);
}, []);
}The caveat from the React docs: "useLayoutEffect can hurt performance. Prefer useEffect when possible."
Running the Audit on a Codebase That Already Exists
The practical workflow: grep for useEffect, then for each match work down the layer list.
grep -rl "useEffect" src/ --include="*.tsx" --include="*.ts" \
| xargs grep -c "useEffect" \
| sort -t: -k2 -rn \
| head -20For each hit, ask: Server Component? Loader? Query hook? Derived value? Event handler? Only if all five answers are no does the effect stay.
Tooling worth turning on: eslint-plugin-react-you-might-not-need-an-effect catches derived-state anti-patterns. The set-state-in-effect rule in eslint-plugin-react-hooks (available in the recommended-latest config) targets double-render patterns where setState is called synchronously inside an effect. One known gap: as of v7.0.1, React.useEffect via namespace import bypasses detection, so grep separately for that pattern.
npm install --save-dev \
eslint-plugin-react-hooks \
eslint-plugin-react-you-might-not-need-an-effect \
eslint-plugin-react-hooks-addonsThese tools catch the within-component anti-patterns automatically. The cross-layer migrations, moving a fetch from a useEffect to a Server Component or a route loader, need a human.
What changes after the audit: fewer renders, fewer race conditions, smaller client bundles when work moves up to the server, components that read top to bottom instead of bouncing between render and effect. What doesn't change: the legitimate effects (subscriptions, browser APIs, widget integration) read exactly the same after the audit as before. The audit isn't a purge; it's a sort.
The Audit, Applied
"You Might Not Need an Effect" still matters. It just isn't the first question anymore. The first question is which layer owns the work.
Once that becomes the reflex, most of the anti-patterns the React docs warn about stop appearing on their own. They were never really about useEffect. They were about defaulting to the wrong layer because the client-side SPA was the only layer that existed.
As Server Components have stabilized and the React Compiler has emerged, some legitimate useEffect calls may become less necessary, not more. Effect dependency management remains an important topic in React development. React has introduced new primitives like use() and useSyncExternalStore for related use cases. The durable skill isn't memorizing the anti-pattern list. It's the layer reflex.
Try the audit on one component this week. Start at the top of the layer list, work down, and stop at the first layer that fits. The wins compound.
Get Started in Minutes
npx create-strapi-app@latest in your terminal and follow our Quick Start Guide to build your first Strapi project.