DEV Community

Cover image for Stop Using useEffect Like This: 5 Patterns That Are Silently Breaking Your React App
Gavin Cettolo
Gavin Cettolo

Posted on

Stop Using useEffect Like This: 5 Patterns That Are Silently Breaking Your React App

Reframing the hook as synchronization

I was doing a code review for a colleague when I found it.

The component had five useEffect hooks.

No errors. No warnings in the console. The PM had signed off on it. It had been in production for three months.

But there was a subtle bug that only showed up when the user navigated quickly between pages.

Data would flash. State would reset. Sometimes the old user's name would appear for a split second before updating to the new one.

Three hours later, we traced it back to a single misused useEffect.

That's the thing about this hook: it fails silently, in slow motion, and always at the worst moment.


TL;DR

  • useEffect is the most misused hook in React and most bugs that seem "mysterious" trace back to it.
  • Five specific patterns are responsible for 90% of the problems: derived state, overloaded effects, stale closures, unstructured fetches, and unnecessary usage.
  • React 18's Strict Mode adds a layer of complexity that catches many of these bugs in development, if you know what to look for.

Table of Contents


Why useEffect Is Deceptively Hard

At first glance, useEffect looks simple:

useEffect(() => {
  // do something
}, [])
Enter fullscreen mode Exit fullscreen mode

It feels like: "run this code when something happens."

But the React docs describe it very differently:

useEffect is a hook that lets you synchronize a component with an external system.

That word, synchronize, changes everything.

It's not a lifecycle replacement. It's not an event handler. It's a way to keep your UI in sync with something outside of React.

The moment you treat it as a general-purpose utility, you open the door to every problem we're about to look at.


Pattern 1: Deriving State Inside useEffect

This is the most common mistake, and the one I see even in senior developers' code.

// ❌ Don't do this
const [fullName, setFullName] = useState<string>('')

useEffect(() => {
  setFullName(`${firstName} ${lastName}`)
}, [firstName, lastName])
Enter fullscreen mode Exit fullscreen mode

This looks harmless. It even feels clean.

But here's what actually happens under the hood:

  1. Component renders with firstName and lastName.
  2. React runs the effect after the render.
  3. setFullName triggers another render.
  4. The user briefly sees an empty or stale fullName. You've just added an extra render cycle for no reason.

The fix

// ✅ Compute it during render
const fullName = `${firstName} ${lastName}`
Enter fullscreen mode Exit fullscreen mode

That's it. No state. No effect. No extra render. No stale flash.

Rule of thumb: If you can compute a value from existing props or state, never put it in useState. Compute it inline.

The React docs even have a whole section on this called "You might not need an effect", worth bookmarking.


Pattern 2: Overloading a Single useEffect

This one grows gradually. It starts innocent:

// ❌ Three completely unrelated things in one effect
useEffect(() => {
  fetchUser(userId)
  setupWebSocketSubscription()
  document.title = `Dashboard - ${appName}`
}, [userId, appName])
Enter fullscreen mode Exit fullscreen mode

Now imagine this in two months, after three more developers have touched it.

The problem isn't just readability. It's coupling.

If fetchUser starts causing issues, you need to reason about the subscription and the document title at the same time.

If appName changes, your effect re-runs, which means you're also re-fetching the user and re-subscribing unnecessarily.

The fix

One effect, one responsibility:

// ✅ Each effect has a single, clear purpose
useEffect(() => {
  fetchUser(userId)
}, [userId])

useEffect(() => {
  const unsubscribe = setupWebSocketSubscription()
  return () => unsubscribe()
}, [])

useEffect(() => {
  document.title = `Dashboard - ${appName}`
}, [appName])
Enter fullscreen mode Exit fullscreen mode

Notice the cleanup function in the second effect, that's the bonus you get when you split effects: cleanups become obvious and natural.


Pattern 3: Missing or Suppressed Dependencies

We've all done this at least once.

// ❌ The eslint-disable comment is a red flag, not a solution
useEffect(() => {
  processOrder(orderId, userPreferences)
  // eslint-disable-next-line react-hooks/exhaustive-deps
}, [])
Enter fullscreen mode Exit fullscreen mode

The eslint-disable comment is almost always a sign that something in the design needs to change, not that the linter is wrong.

Here's the subtle bug this creates:

// What the developer thinks:
// "This runs once on mount, using the current orderId."

// What actually happens:
// "This captures orderId at mount time and never updates,
//  even if orderId changes while the component is still mounted."
Enter fullscreen mode Exit fullscreen mode

This is called a stale closure. The effect "remembers" the old value of orderId forever.

The fix

// ✅ Be explicit about what the effect depends on
useEffect(() => {
  processOrder(orderId, userPreferences)
}, [orderId, userPreferences])
Enter fullscreen mode Exit fullscreen mode

If adding the dependency causes unwanted re-runs, the real fix is usually one of these:

  • Move the value outside the component (if it's static).
  • Use useCallback or useMemo to stabilize the reference.
  • Reconsider whether the effect is the right tool for the job. Never silence the linter. Fix the design.

Pattern 4: Fetching Data Without Structure

This is the one everyone starts with:

// ❌ Simple, but missing critical pieces
useEffect(() => {
  fetch(`/api/users/${userId}`)
    .then(res => res.json())
    .then(setUser)
}, [userId])
Enter fullscreen mode Exit fullscreen mode

What's wrong with it? Let me count:

  • No loading state: the UI hangs silently.
  • No error handling: if the request fails, nothing happens.
  • No cancellation: if userId changes before the request completes, you get a race condition.
  • Not reusable: you'll copy-paste this into ten components. The race condition is the nastiest one. Here's what it looks like:
// ❌ Race condition scenario
// 1. userId changes to "user-2"
// 2. Effect runs, starts fetching "user-2"
// 3. userId changes again to "user-3"
// 4. Effect runs, starts fetching "user-3"
// 5. "user-3" response arrives first
// 6. "user-2" response arrives and overwrites "user-3" data
// 7. You're now showing the wrong user's data
Enter fullscreen mode Exit fullscreen mode

The fix: a custom hook

At minimum, extract the logic and handle cancellation:

// ✅ Custom hook with loading, error, and cleanup
function useUser(userId: string) {
  const [user, setUser] = useState<User | null>(null)
  const [isLoading, setIsLoading] = useState(true)
  const [error, setError] = useState<Error | null>(null)

  useEffect(() => {
    let cancelled = false
    setIsLoading(true)

    fetch(`/api/users/${userId}`)
      .then(res => {
        if (!res.ok) throw new Error('Failed to fetch user')
        return res.json()
      })
      .then(data => {
        if (!cancelled) setUser(data)
      })
      .catch(err => {
        if (!cancelled) setError(err)
      })
      .finally(() => {
        if (!cancelled) setIsLoading(false)
      })

    return () => {
      cancelled = true
    }
  }, [userId])

  return { user, isLoading, error }
}
Enter fullscreen mode Exit fullscreen mode

The cancelled flag is the key: it prevents state updates after the component has moved on.

The fix: use a library (recommended)

For production apps, don't reinvent the wheel. Libraries like TanStack Query (formerly React Query) handle all of this for you, caching, deduplication, background refetching, race conditions, and more.

// ✅ TanStack Query: this replaces ~30 lines of manual code
import { useQuery } from '@tanstack/react-query'

function UserProfile({ userId }: { userId: string }) {
  const { data: user, isLoading, error } = useQuery({
    queryKey: ['user', userId],
    queryFn: () => fetch(`/api/users/${userId}`).then(res => res.json()),
  })

  if (isLoading) return <Spinner />
  if (error) return <ErrorMessage error={error} />

  return <div>{user.fullName}</div>
}
Enter fullscreen mode Exit fullscreen mode

If you're not using TanStack Query yet, it's one of the highest-value libraries you can add to a React project.

TanStack Query

Powerful asynchronous state management, server-state utilities and data fetching. Fetch, cache, update, and wrangle all forms of async data in your TS/JS, React, Vue, Solid, Svelte & Angular applications all without touching any "global state"

favicon tanstack.com

Pattern 5: Using useEffect When You Simply Don't Need It

Sometimes useEffect gets used just because it's the only tool that feels right:

// ❌ This isn't a side effect, it's just logic
useEffect(() => {
  if (items.length === 0) {
    setIsEmpty(true)
  } else {
    setIsEmpty(false)
  }
}, [items])
Enter fullscreen mode Exit fullscreen mode

Or even:

// ❌ This belongs in an event handler, not an effect
useEffect(() => {
  if (formSubmitted) {
    validateAndSave()
  }
}, [formSubmitted])
Enter fullscreen mode Exit fullscreen mode

The second one is a particularly sneaky antipattern. If you're reacting to a user action (a button click, a form submit), use an event handler. Effects are for syncing with external systems, not for responding to user interactions.

The fix

// ✅ Derived value, not state
const isEmpty = items.length === 0

// ✅ Event handler for user interactions
function handleSubmit() {
  setFormSubmitted(true)
  validateAndSave()
}
Enter fullscreen mode Exit fullscreen mode

Ask yourself before writing any useEffect:
"What external system am I syncing with?"

If the answer is "nothing, I'm just reacting to some state", you don't need an effect.


Bonus: React 18, Strict Mode, and the Double-Mount Trap

This section trips up a lot of developers upgrading to React 18.

In development mode, React 18's Strict Mode intentionally mounts your components twice.

This is new. And it's by design.

React is preparing for a future feature (concurrent rendering and resumable state) where components may be mounted, unmounted, and remounted without losing state. To catch bugs early, Strict Mode simulates this in development.

Here's what happens:

Component mounts   → Effect runs
Component unmounts → Cleanup runs
Component mounts   → Effect runs again  ← This is new in React 18
Enter fullscreen mode Exit fullscreen mode

If your effects are written correctly (with proper cleanup), you'll never notice this. If they're not, you'll see strange behaviors:

// ❌ This effect causes a double API call in Strict Mode
useEffect(() => {
  fetch('/api/init')
    .then(res => res.json())
    .then(setData)
}, [])
Enter fullscreen mode Exit fullscreen mode

In development with React 18 Strict Mode, this runs twice. In production, it runs once.

This confuses developers into thinking their code is broken. It's not broken, it's exposing that the code wasn't safe to run twice.

The fix

// ✅ With cleanup, the double-mount is harmless
useEffect(() => {
  let cancelled = false

  fetch('/api/init')
    .then(res => res.json())
    .then(data => {
      if (!cancelled) setData(data)
    })

  return () => {
    cancelled = true
  }
}, [])
Enter fullscreen mode Exit fullscreen mode

Now the second mount runs the effect again, but the cleanup from the first run has already set cancelled = true, so the first fetch's result is ignored.

Key insight: If your effect breaks when it runs twice, it was always broken. Strict Mode is just making it visible.


Before vs After: A Real Refactoring

Let me show you how these patterns compound in a real component.

This is similar to what I found during that code review I mentioned at the start:

// ❌ Before: a component that "works" but has 4 different issues
interface Props {
  userId: string
}

function UserDashboard({ userId }: Props) {
  const [user, setUser] = useState<any>(null)
  const [fullName, setFullName] = useState<string>('')
  const [isAdmin, setIsAdmin] = useState<boolean>(false)

  // Issue 1: Derives state that could be computed inline
  // Issue 2: Mixes fetch logic with transformation
  useEffect(() => {
    fetch(`/api/users/${userId}`)
      .then(res => res.json())
      .then(data => {
        setUser(data)
        setFullName(`${data.firstName} ${data.lastName}`)
        setIsAdmin(data.role === 'admin')
      })
    // Issue 3: Missing userId dependency
    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, [])

  // Issue 4: Effect for something that isn't a side effect
  useEffect(() => {
    if (user) {
      console.log('User loaded:', user.id)
    }
  }, [user])

  return (
    <div>
      <h1>{fullName}</h1>
      {isAdmin && <AdminBadge />}
    </div>
  )
}
Enter fullscreen mode Exit fullscreen mode

Problems:

  • fullName and isAdmin are derived state, extra renders for nothing.
  • The eslint-disable is hiding a stale closure bug.
  • The logging effect is just noise.

- No loading state, no error handling, no cleanup.

// ✅ After: clean, predictable, and reusable

interface User {
  id: string
  firstName: string
  lastName: string
  role: 'admin' | 'user'
}

// Data fetching logic extracted into a custom hook
function useUser(userId: string) {
  const [user, setUser] = useState<User | null>(null)
  const [isLoading, setIsLoading] = useState<boolean>(true)
  const [error, setError] = useState<Error | null>(null)

  useEffect(() => {
    let cancelled = false
    setIsLoading(true)

    fetch(`/api/users/${userId}`)
      .then(res => {
        if (!res.ok) throw new Error('Failed to fetch')
        return res.json() as Promise<User>
      })
      .then(data => {
        if (!cancelled) {
          setUser(data)
          setIsLoading(false)
        }
      })
      .catch(err => {
        if (!cancelled) {
          setError(err)
          setIsLoading(false)
        }
      })

    return () => { cancelled = true }
  }, [userId])

  return { user, isLoading, error }
}

// Component is now clean and purely presentational
function UserDashboard({ userId }: { userId: string }) {
  const { user, isLoading, error } = useUser(userId)

  if (isLoading) return <Spinner />
  if (error) return <p>Something went wrong.</p>
  if (!user) return null

  // Derived values computed inline, no state, no effects
  const fullName = `${user.firstName} ${user.lastName}`
  const isAdmin = user.role === 'admin'

  return (
    <div>
      <h1>{fullName}</h1>
      {isAdmin && <AdminBadge />}
    </div>
  )
}
Enter fullscreen mode Exit fullscreen mode

What changed:

  • Zero derived state, fullName and isAdmin are computed inline.
  • One effect, one responsibility, and it's properly cleaned up.
  • No suppressed linter warnings.
  • Loading and error states handled explicitly.
  • The component is now easy to test, easy to read, and easy to modify. Same functionality. Dramatically different maintainability.

A Better Mental Model

Here's the shift that changes how you write React:

Old mental model: "When should this code run?"

New mental model: "What external system am I keeping in sync?"

External systems that useEffect is appropriate for:

  • APIs and data sources: fetching data from a server.
  • Browser APIs: document.title, localStorage, IntersectionObserver.
  • Third-party libraries: charts, maps, analytics SDKs that live outside React's tree.
  • Subscriptions: WebSockets, event listeners, timers. If none of these describe what you're doing, you probably don't need useEffect.

Before writing one, run through this quick checklist:

  • [ ] Can I compute this value during render instead?
  • [ ] Is this reacting to a user action? (Use an event handler.)
  • [ ] Is this effect doing more than one thing? (Split it.)
  • [ ] Do I have a cleanup function? (If not, why not?)
  • [ ] Would this break if it ran twice? (If yes, it was already broken.)

Final Thoughts

useEffect isn't bad.

It's one of the most powerful primitives React gives you.

But power without understanding leads to exactly the kind of silent, slow-burn bugs that show up three months after launch, on a Friday afternoon, right before a demo.

The goal isn't to use useEffect correctly.

The goal is to need it less.

Every unnecessary effect you remove is:

  • One fewer render cycle.
  • One fewer stale closure waiting to happen.
  • One fewer thing to explain to the next developer who reads your code. Write less. Compute more. Clean up always.

Which of these five patterns have you fallen into this week? Drop your story in the comments, the more specific, the better. I've shared mine. Now it's your turn.

If this was useful, a ❤️ or a 🦄 unicorn helps more people find it.

And if you want more of this kind of deep-dive into everyday React patterns, follow me here on DEV! I publish weekly.

Top comments (48)

Collapse
 
elenchen profile image
Elen Chen

I really liked your framing of useEffect as “synchronization with an external system” instead of a lifecycle replacement. That alone reframes a lot of bad habits. I’ve definitely written effects that were basically “run this after render” without asking why. Your derived state example hit close to home. I’ve done that fullName pattern so many times thinking it was clean. It’s interesting how something that looks declarative actually introduces an extra render and potential flicker.

Do you think this misunderstanding mostly comes from people migrating from class components?

Collapse
 
gavincettolo profile image
Gavin Cettolo

Yeah, 100%. That mental model is inherited from componentDidMount / componentDidUpdate. People map useEffect directly onto those, but React’s docs explicitly define it differently, as synchronization, not lifecycle management .

The tricky part is that the old mental model works just enough to pass tests and reviews, so the issues only show up later under stress (like fast navigation or race conditions). That’s why these bugs feel so “mysterious.”

Collapse
 
elenchen profile image
Elen Chen

That “works just enough” point is so accurate. I also liked your “one effect, one responsibility” rule. I’ve seen massive effects handling fetches, subscriptions, and DOM updates all in one place, and debugging them becomes a nightmare. But I’ve also heard some devs argue that splitting effects can make things harder to follow. How do you balance readability vs separation?

Thread Thread
 
gavincettolo profile image
Gavin Cettolo

Good question. I think it comes down to coupling vs cohesion. When you group unrelated concerns into one effect, you’re coupling them artificially. Splitting them might increase the number of hooks, but each becomes easier to reason about independently.
Also, splitting naturally forces you to define proper dependencies and cleanups, which is where a lot of bugs hide. In my experience, more smaller effects = less cognitive load over time.

Thread Thread
 
elenchen profile image
Elen Chen

The stale closure section was probably the most important part for me. The eslint-disable example is something I’ve literally seen in production code. It’s almost treated as a “fix,” when it’s actually hiding the problem. The explanation that the effect “remembers” the initial values forever really clicked. I feel like this is one of those concepts that’s obvious once explained but hard to internalize at first.

Thread Thread
 
gavincettolo profile image
Gavin Cettolo

Exactly. It’s subtle because nothing crashes, the app just behaves slightly wrong, and those bugs often only show up under specific conditions.

The linter rule (react-hooks/exhaustive-deps) is actually trying to protect you from that, so disabling it is usually a design smell, not a solution . If dependencies feel “annoying”, it usually means the logic belongs somewhere else (like a custom hook or event handler).

Thread Thread
 
elenchen profile image
Elen Chen

Speaking of subtle bugs, your fetch race condition example was great. That “user-3 loads, then user-2 overwrites it” scenario is something I’ve seen but never fully understood. The cancellation pattern you showed makes sense, but it also feels like a lot of boilerplate. Is that why you mentioned tools like TanStack Query?

Thread Thread
 
gavincettolo profile image
Gavin Cettolo

Yeah, exactly!
Once you start handling loading state, errors, cancellation, caching, etc., it becomes clear that rolling your own fetch logic repeatedly isn’t ideal. Libraries like TanStack Query exist to abstract that complexity.

The key takeaway isn’t “always use a library”, but rather: if your effect starts growing state management logic, that’s a signal to extract or delegate it.

Thread Thread
 
elenchen profile image
Elen Chen

The “you might not need an effect” section might be the most valuable part of the article. I’ve definitely used useEffect for things that were just conditional logic or event-driven actions. Especially the form submission example, I’ve done that exact pattern. It’s almost like useEffect becomes a hammer for everything.

Thread Thread
 
gavincettolo profile image
Gavin Cettolo

Totally. It’s the most flexible hook, so it gets overused, but React’s direction is actually to reduce the need for it by introducing more specialized APIs.

A good sanity check is the question I mentioned: what external system am I syncing with?. If you can’t answer that clearly, it’s probably not an effect.

Thread Thread
 
elenchen profile image
Elen Chen

The React 18 Strict Mode part was also super helpful. I remember being confused when effects ran twice and thinking something was broken. Your explanation that it’s a “stress test” makes a lot more sense now. It actually aligns with the idea that effects should be safe to run multiple times.

Thread Thread
 
gavincettolo profile image
Gavin Cettolo

Exactly, Strict Mode is exposing assumptions in your code. React intentionally runs setup → cleanup → setup to ensure your logic is resilient.
If an effect breaks under that pattern, it means it wasn’t safe to begin with. Once you embrace that, it becomes a really powerful debugging tool rather than an annoyance.

Thread Thread
 
elenchen profile image
Elen Chen

I agree with most of your points, but I’m still not fully convinced about avoiding derived state in all cases. For example, when computations are expensive, wouldn’t storing the result in state (via useEffect) sometimes be justified instead of recalculating on every render?

Thread Thread
 
gavincettolo profile image
Gavin Cettolo

That’s a fair pushback. In those cases, I’d usually reach for useMemo instead of useEffect. The key difference is that useMemo keeps the computation tied to render, while useEffect introduces an additional render cycle.

So yes, optimization is valid, but useEffect is rarely the right tool for it unless you’re syncing with something external.

Thread Thread
 
elenchen profile image
Elen Chen

That makes sense. I think part of the confusion is that React doesn’t make these boundaries super explicit when you’re learning. Everything is “just hooks,” so it’s easy to mix responsibilities. Your article kind of highlights that useEffect is more of an escape hatch than a default tool.

Thread Thread
 
gavincettolo profile image
Gavin Cettolo

Exactly, “escape hatch” is the perfect term. The React team has been emphasizing that direction more recently as well. If you find yourself writing a lot of effects, it’s often a signal that your component design could be simplified.

Thread Thread
 
elenchen profile image
Elen Chen

One thing I’ve run into in real-world apps is that sometimes effects start simple but grow over time, especially in feature-heavy components.
Do you have a strategy for refactoring effects before they become unmanageable?

Thread Thread
 
gavincettolo profile image
Gavin Cettolo

Yeah, that’s very common. My rule of thumb is: as soon as an effect has more than one concern, I split it. And if it starts managing its own mini state machine (loading, error, retries, etc.), I extract it into a custom hook or move it to a dedicated data layer.
Catching it early is key, once it grows too big, refactoring becomes much harder.

Thread Thread
 
elenchen profile image
Elen Chen

I also appreciated that you didn’t just say “don’t do this,” but explained why these patterns fail. The race condition example especially helped connect the dots for me. It’s one of those bugs that feels random until you see the sequence clearly.

Thread Thread
 
gavincettolo profile image
Gavin Cettolo

Glad that part resonated. Those are my favorite kinds of examples, the ones that explain bugs people have already seen but couldn’t fully diagnose. React’s async rendering model makes these issues more visible, which is why patterns that seemed fine before start breaking now.

Thread Thread
 
elenchen profile image
Elen Chen

Do you think newer React features (like server components or data fetching at the framework level) will eventually reduce the need for useEffect even further?

Thread Thread
 
gavincettolo profile image
Gavin Cettolo

Definitely. That’s already happening. Frameworks are moving data fetching out of components entirely, which eliminates a huge category of effects.
I think long-term, useEffect will mostly be used for true side effects, things like subscriptions, integrations, or imperative APIs, not general app logic.

Thread Thread
 
elenchen profile image
Elen Chen

That actually makes the hook feel less intimidating. Instead of being this all-purpose tool, it’s more like a specialized bridge between React and the outside world.
Anyway, really solid article, it clarified a lot of things I had been doing by habit rather than understanding.

Thread Thread
 
elenchen profile image
Elen Chen

Thank you @gavincettolo

Thread Thread
 
gavincettolo profile image
Gavin Cettolo

I really appreciate that and thanks for the thoughtful questions throughout this thread.
These kinds of discussions are exactly why I wrote the article. If it made you rethink even one useEffect, then it did its job.
Thank you @elenchen

Collapse
 
paolozero profile image
Paolo Zero

As a React developer, this article hit way too close to home.

I’ve reviewed codebases where useEffect wasn’t just overused, it basically became the app’s unofficial state machine. Everything flowed through it: data fetching, derived values, UI flags, even user actions. And just like described in the article, nothing looked broken… until it was. Subtle flickers, race conditions, ghost state — the kind of bugs that make you question your sanity at 2am.

What I really appreciate here is the shift in mental model: useEffect is for synchronization with external systems, not for orchestrating your app’s logic. That single idea explains most of the five anti-patterns discussed.

The “derived state in useEffect” trap

I’ve personally written code like:

useEffect(() => {
  setFullName(firstName + ' ' + lastName)
}, [firstName, lastName])
Enter fullscreen mode Exit fullscreen mode

At the time it felt clean. In reality, it introduces an unnecessary render cycle and opens the door to UI inconsistencies. The article calls this out perfectly — compute during render, don’t synchronize what’s already inside React.

And honestly, once you unsee this, you start spotting it everywhere. Even in “senior-level” codebases.

Overloaded effects = hidden coupling

The “one effect doing three unrelated things” example is another classic. I’ve seen effects that fetch data, set document title, and wire up subscriptions all together — and then someone adds a dependency and suddenly everything re-runs for no clear reason.

This is where bugs become emergent behavior. Not obvious, not reproducible, just… weird.

Splitting effects by responsibility isn’t just cleaner — it’s the difference between predictable and chaotic systems.

The eslint-disable anti-pattern 🚨

If I see:

// eslint-disable-next-line react-hooks/exhaustive-deps
Enter fullscreen mode Exit fullscreen mode

I already know there’s a design issue.

The stale closure problem mentioned in the article is real and nasty: your effect silently captures outdated values and keeps using them forever.
And the worst part? It often “works” until it doesn’t.

Data fetching: where things get dangerous

The race condition example is 🔥 because it’s so common and so misunderstood. Two quick state changes, two requests, wrong response wins — boom, inconsistent UI.

This is exactly why I’ve become a big fan of libraries like React Query / TanStack Query. Not because they’re trendy, but because they encode the correct mental model:

  • async state is different
  • caching matters
  • cancellation matters
  • effects alone are too low-level for this

The uncomfortable truth: you probably don’t need that effect

This was the biggest mindset shift for me.

A lot of effects are just:

  • derived values → compute inline
  • reactions to user actions → event handlers
  • internal data flow → just React doing its job

Even the React docs push this idea now, and the article reinforces it well: if you’re not syncing with something outside React, question why the effect exists.

React 18 made everything more obvious (and painful)

Strict Mode double-invoking effects exposed so many hidden issues in legacy codebases. And yeah, it’s annoying at first — but it’s also brutally honest.

If your effect breaks when run twice, it was already broken. The article nails this point.

Final thought from my experience

The biggest React code quality improvements I’ve ever seen didn’t come from adding patterns — they came from removing unnecessary useEffects.

Less synchronization → fewer edge cases
Fewer effects → fewer race conditions
Simpler data flow → easier reasoning

Or said differently:

The best useEffect is the one you didn’t need to write.

Curious if others had the same “aha” moment when they stopped treating useEffect like componentDidMount 2.0.

Collapse
 
gavincettolo profile image
Gavin Cettolo

Hey @paolozero, this is a fantastic reflection. You basically described the exact journey that pushed me to write the article in the first place.

On useEffect becoming a “state machine”

What you said here is spot on. When everything flows through useEffect, the component stops being declarative and starts behaving like an imperative system hidden inside React. It often feels fine at the beginning, then complexity builds up and the bugs you mentioned start appearing.

The tricky part is that React does not stop you from doing this. It quietly allows it, which is why so many teams fall into the pattern.

Derived state

Your example with fullName is perfect. It looks harmless, even clean, but it introduces an extra render and unnecessary synchronization.

What I have noticed is that developers often reach for useEffect when they feel the need to “react” to something. The shift is realizing that React already reacts during render. If the data is already there, we should compute it directly.

Splitting effects

I am really glad you highlighted this. Mixing concerns inside a single effect is one of the biggest sources of unpredictable behavior.

Each effect should answer one simple question: what external thing am I synchronizing with?

If the answer contains more than one concern, it is usually a sign that the effect should be split.

Disabling the lint rule

Yes, this is often a smell. Not always wrong, but in most cases it hides a deeper issue.

The stale closure problem is subtle because it does not fail loudly. It just drifts away from the current state over time. That makes it hard to debug and even harder to notice during reviews.

Data fetching and race conditions

I completely agree with your take on libraries like TanStack Query. The core issue is that useEffect is too low level for managing async state correctly.

Handling cancellation, caching, and consistency manually inside effects quickly becomes complex. Libraries help because they encode best practices and remove a lot of footguns.

“You probably don’t need that effect”

This is the key idea I hoped people would take away.

Many effects exist because of habit, not necessity. Once you start questioning each one, the codebase becomes simpler and more predictable.

React 18 and Strict Mode

You are right, the double invocation exposed many hidden issues. It can be frustrating, but it is also a great diagnostic tool.

If an effect cannot handle being run more than once, it is often a sign that it is doing too much or doing the wrong thing.

Final note

I really like how you framed it: improvements often come from removing effects rather than adding patterns.

That is exactly the mindset shift. useEffect is powerful, but it should be treated as a last mile tool for synchronization, not the foundation of your logic.

Thanks again for such a thoughtful comment. It adds a lot of depth to the discussion.

Collapse
 
lucaferri profile image
Luca Ferri

Really enjoyed this. The point about useEffect being about synchronization instead of “run this when X changes” hit hard. I think most of us still carry the old lifecycle mental model.

Collapse
 
gavincettolo profile image
Gavin Cettolo

Exactly, that mental shift is the whole game. Once you stop thinking in lifecycle terms, a lot of those “mysterious bugs” just disappear.

Collapse
 
lucaferri profile image
Luca Ferri

The derived state example (fullName) is painfully relatable. I’ve written that exact pattern dozens of times. It feels clean until you realize it adds an extra render cycle and can even flash stale data.

Thread Thread
 
gavincettolo profile image
Gavin Cettolo

Yeah, it’s one of those patterns that looks harmless but compounds over time. Removing it often simplifies both performance and readability.

Thread Thread
 
lucaferri profile image
Luca Ferri

I also liked the “one effect, one responsibility” idea. I’ve definitely seen (and written) those giant effects doing fetch + subscriptions + DOM updates all together.

Thread Thread
 
gavincettolo profile image
Gavin Cettolo

Those are the hardest to debug too.
Splitting them not only improves clarity, but also makes cleanup logic much more obvious.

Thread Thread
 
lucaferri profile image
Luca Ferri

The stale closure explanation was 🔥. Especially the part about developers thinking “this runs once” while it actually captures outdated values forever.

Thread Thread
 
gavincettolo profile image
Gavin Cettolo

That’s probably the most dangerous one because nothing crashes, it just behaves incorrectly under certain conditions.

Thread Thread
 
lucaferri profile image
Luca Ferri

Your section on data fetching really resonates. The race condition example with multiple userId changes is something I’ve seen in production more than once.

Thread Thread
 
gavincettolo profile image
Gavin Cettolo

Same here. That’s why I push people toward custom hooks or libraries, manual fetch logic in components rarely scales well.

Thread Thread
 
lucaferri profile image
Luca Ferri

Also appreciated the point about not using useEffect for user actions. I think people default to effects when a simple event handler would be clearer.

Thread Thread
 
gavincettolo profile image
Gavin Cettolo

Totally. If it’s triggered by a user interaction, it usually belongs in the handler, not in an effect reacting to state.

Thread Thread
 
lucaferri profile image
Luca Ferri

The React 18 Strict Mode section is gold. The “if it breaks when it runs twice, it was already broken” line should be printed on a poster.

Thread Thread
 
gavincettolo profile image
Gavin Cettolo

Haha, I stand by that. Strict Mode just exposes the cracks earlier, which is exactly what we want as developers.

Thread Thread
 
lucaferri profile image
Luca Ferri

Thank you @gavincettolo for your time, I was really triggered by this post and started thinking a lot.
I hope I haven't disturbed you!

Thread Thread
 
gavincettolo profile image
Gavin Cettolo

Don't worry @lucaferri, it's always a pleasure to have a constructive discussion on interesting topics.

Collapse
 
marina_eremina profile image
Marina Eremina

Recognizable patterns! I’d just like to add something regarding the “Missing or Suppressed Dependencies” antipattern, it’s surely correct that we shouldn’t suppress dependencies (though the eslint-disable-next-line ESLint rule is familiar to every React developer 😄), there are cases where a dependency technically should be included, but from the logic percpective shouldn’t rerun the effect.

That’s exactly the kind of situation React introduced useEffectEvent for, it lets you extract that dependency from the effect while still accessing its latest value.

Collapse
 
gavincettolo profile image
Gavin Cettolo

Absolutely agree, and that’s a great addition!

"useEffectEvent" is exactly the kind of tool that helps solve the “I need the latest value, but I don’t want this effect to resubscribe/re-run” dilemma without fighting the linter or hiding dependencies. React introduced it precisely to separate reactive synchronization from event-like logic.

A lot of developers still reach for refs or "eslint-disable-next-line", so it’s good to highlight that there’s now an official pattern for these cases 😄

Collapse
 
brense profile image
Rense Bakker

Its better to use an abort controller signal when you want to cancel a http request: developer.mozilla.org/en-US/docs/W...

And I would disagree that tan stack query is the most valuable thing you can install. We've actually had many issues with tan stack query in production, because it lacks any kind of middleware or plugin architecture, adding things like authentication or custom logging or error handling becomes a big mess after a while.

Collapse
 
gavincettolo profile image
Gavin Cettolo

Good points, you’re absolutely right about "AbortController". That’s the cleaner and more modern approach for canceling fetch requests compared to the old “ignore stale response” pattern. Definitely something worth using whenever the API supports signals.

And fair criticism regarding TanStack Query too. I still think it brings huge value for caching, synchronization, retries, and request state management, especially compared to hand-rolled "useEffect" fetching. But I agree that once applications become large and need cross-cutting concerns like auth flows, centralized logging, or custom error pipelines, the lack of a true middleware architecture can become noticeable.

In the end, I see it less as “the perfect solution” and more as “a massive improvement over manual effect-based data fetching”.

Some comments may only be visible to logged-in visitors. Sign in to view all comments.