All posts
React & Next.js··9 min read

You Probably Do Not Need useEffect: Patterns That Replace It

Most useEffect calls I review are bugs waiting to happen. Here are the patterns that replace effects for derived state, data fetching, and event logic.

By

On this page

I reviewed a 40-component dashboard last month. It had 61 useEffect calls. After the cleanup it had 9. The other 52 were not synchronizing with anything external — they were computing values, copying props into state, reacting to clicks, and re-fetching data on every render. Each one was a small machine for generating stale state, double renders, and race conditions.

useEffect is not a lifecycle hook. It is an escape hatch for synchronizing your component with a system that lives outside React: the DOM, a WebSocket, localStorage, a third-party chart widget. If the thing you are reacting to is a render, a prop, or a user event, an effect is almost always the wrong tool. Here is how I decide, with the before/after I actually ship.

Derived state: compute during render

This is the single most common offender. Someone has a value that can be calculated from props or state, so they store it in its own state and sync it with an effect.

// Before: two renders, a stale window, and a useless piece of state
function Invoice({ items }: { items: LineItem[] }) {
  const [total, setTotal] = useState(0);
 
  useEffect(() => {
    setTotal(items.reduce((sum, i) => sum + i.price * i.qty, 0));
  }, [items]);
 
  return <strong>{formatUSD(total)}</strong>;
}

On first render total is 0. React paints, runs the effect, calls setTotal, and renders again. For one frame the user can see $0.00. The fix is to delete the state and the effect entirely.

// After: one render, no stale state, no effect
function Invoice({ items }: { items: LineItem[] }) {
  const total = items.reduce((sum, i) => sum + i.price * i.qty, 0);
  return <strong>{formatUSD(total)}</strong>;
}

"But the reduce runs on every render." Yes — and for a few hundred line items that is microseconds. Do not reach for useMemo on reflex. Reach for it when you have measured the cost (React DevTools Profiler, or a performance.now() around the computation) and it shows up, or when the value is a referential dependency of another hook or a memoized child. Otherwise useMemo is pure overhead: it allocates a dependency array and runs comparisons to save you nothing.

// useMemo only when the work is real or referential identity matters
const sortedRows = useMemo(
  () => rows.slice().sort(comparator),   // 50k rows, comparator is non-trivial
  [rows, comparator],
);

Rule of thumb: if you can compute it during render, do. State is for things React cannot recover by re-rendering.

Resetting state on a prop change: use the key

The second classic: a component holds local state, and when some identifying prop changes you want that state to reset. People write an effect that watches the prop and calls a setter.

// Before: resets one render late, and you have to enumerate every field
function CommentBox({ userId }: { userId: string }) {
  const [draft, setDraft] = useState("");
 
  useEffect(() => {
    setDraft("");        // forgot to also reset scroll, validation, etc.
  }, [userId]);
 
  return <textarea value={draft} onChange={(e) => setDraft(e.target.value)} />;
}

The effect runs after React has already rendered CommentBox once with the new userId and the old draft. For one frame, user B sees user A's half-typed comment. Instead, tell React these are conceptually different component instances by giving them a different key.

// After: React unmounts the old instance and mounts a fresh one. All state resets.
function CommentBoxWrapper({ userId }: { userId: string }) {
  return <CommentBox key={userId} userId={userId} />;
}
 
function CommentBox({ userId }: { userId: string }) {
  const [draft, setDraft] = useState("");
  return <textarea value={draft} onChange={(e) => setDraft(e.target.value)} />;
}

Changing key throws away the entire subtree and rebuilds it. Every piece of state — draft, scroll position, focus, refs — resets atomically, before paint. No enumeration, no missed field, no stale frame. This is one of the most underused tools in React and it replaces a whole category of effects.

Event-specific logic: put it in the handler

If logic should run because the user did something, it belongs in the event handler — not in an effect that watches state the handler changed. Effects answer "the screen is now showing X, keep it in sync." Handlers answer "the user did Y, respond." Conflating them produces effects that fire on mount, on unrelated re-renders, and in StrictMode twice.

// Before: POST fires whenever `submitted` is true, including on remount
function Checkout() {
  const [submitted, setSubmitted] = useState(false);
 
  useEffect(() => {
    if (submitted) {
      fetch("/api/orders", { method: "POST" });
      showToast("Order placed");
    }
  }, [submitted]);
 
  return <button onClick={() => setSubmitted(true)}>Place order</button>;
}

That submitted flag is a code smell. The POST is not a consequence of state being true; it is a consequence of a click. Move it.

// After: the network call lives where the intent lives
function Checkout() {
  async function handlePlaceOrder() {
    await fetch("/api/orders", { method: "POST" });
    showToast("Order placed");
  }
 
  return <button onClick={handlePlaceOrder}>Place order</button>;
}

Now it runs exactly once, exactly when clicked, and you never double-submit because a parent re-rendered.

Data fetching: server components or a real query library

fetch inside useEffect is the pattern the React team has been trying to kill for years, and for good reason. The naive version has at least four bugs.

// Before: race conditions, no caching, waterfalls, refetch on every keystroke
function Profile({ id }: { id: string }) {
  const [user, setUser] = useState<User | null>(null);
 
  useEffect(() => {
    fetch(`/api/users/${id}`)
      .then((r) => r.json())
      .then(setUser);          // stale response can overwrite a newer one
  }, [id]);
 
  if (!user) return <Spinner />;
  return <h1>{user.name}</h1>;
}

Switch id from 1 to 2 quickly and the response for 1 can land after 2, leaving you showing the wrong user. There is no cache, no retry, no dedupe, no request cancellation. In 2026 you have two good answers.

If you are on Next.js App Router or any RSC setup, fetch on the server. No effect, no loading flash, no client waterfall.

// app/users/[id]/page.tsx — runs on the server, streams HTML
export default async function Page({ params }: { params: Promise<{ id: string }> }) {
  const { id } = await params;
  const user = await getUser(id);   // direct DB call or cached fetch
  return <h1>{user.name}</h1>;
}

If the data must live client-side (it depends on interaction, or you are not on RSC), use a query library. It handles the cancellation, dedupe, caching, and retries you would otherwise reinvent badly.

// After: React Query handles the race, the cache, and the retry for you
import { useQuery } from "@tanstack/react-query";
 
function Profile({ id }: { id: string }) {
  const { data: user, isPending } = useQuery({
    queryKey: ["user", id],
    queryFn: ({ signal }) => fetch(`/api/users/${id}`, { signal }).then((r) => r.json()),
  });
 
  if (isPending) return <Spinner />;
  return <h1>{user.name}</h1>;
}

The signal cancels the in-flight request when id changes, so the race is gone by construction. SWR gives you the same guarantees with a smaller API. Either beats hand-rolled effects.

External stores: useSyncExternalStore

If you are subscribing to something outside React that mutates over time — a Redux-like store, navigator.onLine, matchMedia, a browser API — do not mirror it into state with useEffect + setState. That pattern tears during concurrent rendering: different components can read different values in the same paint. useSyncExternalStore is built for exactly this and is concurrent-safe.

// The right primitive for "read a value from an external system"
import { useSyncExternalStore } from "react";
 
function subscribe(callback: () => void) {
  window.addEventListener("online", callback);
  window.addEventListener("offline", callback);
  return () => {
    window.removeEventListener("online", callback);
    window.removeEventListener("offline", callback);
  };
}
 
export function useOnlineStatus(): boolean {
  return useSyncExternalStore(
    subscribe,
    () => navigator.onLine,   // client snapshot
    () => true,               // server snapshot (assume online during SSR)
  );
}

One hook, no effect, no stale state, no tearing, and it server-renders cleanly.

When useEffect IS the right tool

I am not anti-effect. After the cleanup, those 9 survivors all had one thing in common: they bridged React to a genuinely external system that does not fit render-time computation. That is the whole job description.

// Legit: integrating a non-React widget that owns its own DOM lifecycle
useEffect(() => {
  const chart = new ApexCharts(ref.current!, options);
  chart.render();
  return () => chart.destroy();   // cleanup is mandatory here
}, [options]);

Other honest uses: subscribing to a WebSocket or EventSource, imperatively focusing or measuring DOM after layout, setting document.title outside a framework that handles it, wiring up IntersectionObserver / ResizeObserver, and firing analytics when a screen becomes visible (mount), not when a button is clicked. The tell is always the same — you are reaching out to something React does not own.

Your situationReach for
Value derives from props/stateCompute in render
That computation is measurably expensiveuseMemo
Reset all local state when an id changeskey prop
Logic runs because of a user actionEvent handler
Fetch data on a server-capable stackServer Component
Fetch data that must be client-sideReact Query / SWR
Read a mutable external valueuseSyncExternalStore
Sync with a non-React systemuseEffect

The checklist I run before writing an effect

Before I type useEffect, I answer these in order. The first "yes" tells me what to use instead.

  1. Can I compute this during render from data I already have? Then do — no state, no effect. Add useMemo only after the profiler complains.
  2. Am I trying to reset state because an identifying prop changed? Use a key on the component.
  3. Does this run in response to a specific user interaction? Put it in the event handler.
  4. Am I fetching data? Use a Server Component if you can, a query library if you cannot. Never raw fetch in an effect.
  5. Am I reading a value from an external, mutable source? Use useSyncExternalStore.
  6. Am I genuinely synchronizing with a system outside React — DOM, sockets, browser APIs, third-party widgets, analytics-on-view? Now write the effect — and write the cleanup function in the same keystroke.

If you get to step 6, you have earned your effect. Most of the time you will not get there, and that is the point. Every effect you delete is a class of race condition, double render, and stale-state bug that can no longer happen. Fewer effects is not a style preference — it is a correctness win.