useEffect Is Breaking Your React App? Here’s the Real Reason (and How to Fix It)

Introduction
-
Have you ever added a useEffect to your React component—only to watch your app fall into an infinite loop or behave completely unexpectedly? You’re not alone. It’s one of the most common pain points I see in r/reactjs: folks writing effects that bite them back. In this blog, we’ll go beyond the shallow “just add this to the dependency array” advice. You’ll learn:
-
• Why useEffect actually runs (and re-runs)
-
• What the dependency array really does under the hood
-
• Real debugging stories from my own codebase
-
• Practical patterns to fix and avoid infinite loops
-
• A handy cheatsheet to reason about effects
-
Let’s dive in and finally master useEffect—once and for all.
1. The Bug That Sparked This Post
- Here’s a minimal example that trips up many beginners (and even veterans):
-
What happens?
-
• On mount, count is 0, so effect runs and sets count to 1.
-
• Because count changed, React re-renders, sees count is now 1, runs effect again, sets count to 2.
-
• And on, and on… an infinite loop.
-
At first glance, you might think “just remove count from the array,” but that can introduce stale values, skipped updates, or other hard-to-find bugs. Let’s understand why this loop exists before we fix it. 🔍
2. Behind the Scenes: React’s Effect Model
-
- Render → Commit: React executes your component’s render to produce virtual DOM.
-
- Apply Changes: After commit, React applies real DOM updates.
-
- Run Effects: React then runs all side-effects whose dependencies changed since the last render.
- Dependency array: a list of values React watches. Whenever any value in that array is different (by reference/primitive check), the effect callback runs again.
Analogy: Think of useEffect like a subscription:
- “Hey React, whenever X changes, call this function.”
- But if your function changes X as part of its work, you’ve created a feedback loop.
3. Real Debugging Story: My 3-Hour Rabbit Hole
- Just last week, I spent three hours chasing down a bug in our dashboard. A useEffect would fire twice on initial load—cue confusing flicker and duplicate network calls. Here’s the gist:
useEffect(() => {
fetch('/api/stats')
.then(res => res.json())
.then(data => setStats(data));
}, [stats]);
-
• I’d thought: “I want to re-fetch stats whenever they change.”
-
• But because stats is a new array/object each time, the effect ran endlessly.
-
Lesson learned:
-
Only include dependencies you truly need. In this case, [] (run once) or a more specific key like user-Id made more sense.
4. How to Fix & Avoid Infinite Loops
A. Run Once on Mount
- “Only run this effect once, when the component first mounts, and never again.”
- This is super helpful for things like fetching data from an API, setting up event listeners, or logging to the console — tasks you only want to do one time.
The empty array means no dependencies, so React knows it doesn’t need to re-run this effect after the initial render.
When to Use useEffect(() => {}, [])— Real-Life Examples
1. Fetching Data from an API (like user info, product list, etc.)
- Imagine you have an online store or blog. When the page loads, you want to fetch the list of products or blog posts once from a server. You don’t want the app to keep fetching again and again every time it re-renders. That’s where [] helps — it makes sure the fetch happens only when the page first loads.
2. Loading Data from Local Storage
- Let’s say your app saves the user’s preferences or theme (dark/light mode) in the browser’s local storage. You want to load that preference only once when the app starts, not every time the user clicks or types. Again, [] helps you do just that.
What we learn :
- If you want some logic (like fetching data, connecting to a server, or logging info) to run only once when the component mounts, always use useEffect(() => { ... }, []).
- It’s like saying:
“Hey React, do this only when the page loads — and then don’t bother running it again!”
B. Guard Inside the Effect
- 🚫 How to Prevent Endless Loops with a Condition Inside useEffect
- Sometimes, we want our effect to run more than once, but only up to a certain point. This is where adding a condition or guard inside the useEffect becomes super helpful.
useEffect(() => {
if (count < 5) {
setCount(prev => prev + 1);
}
}, [count]);
Add a conditional guard to stop at a threshold—preventing endless updates.
- In the image example, the code is using a counter (count) that updates itself inside the effect. But if we don't stop it somewhere, it could keep increasing forever, causing the app to crash or freeze.
- So, we add a guard condition — like:
- “Only run this logic if count is less than 5.”
- This way, once count reaches 5, the condition becomes false and the useEffect stops triggering updates.
Real-Life Examples of Guard Conditions
1. Creating a Limited Loop or Auto Increment
- If you want something to increase or change a few times, like showing a countdown from 5 to 1 or auto-playing a few slides, you can use a guard to stop it after the desired number.
2. Sending Reminders or Messages
- Maybe you want to send a reminder every second for 5 seconds. The guard ensures it only happens 5 times, and not endlessly.
What we learn :
- In this example, we learn how to prevent infinite loops in React by adding a guard condition inside the useEffect hook. When you update state inside useEffect, it can cause the effect to run again and again unless you set a stopping point. By using a condition like if (count < 5), you make sure the update only happens up to a certain limit. Adding a guard keeps your app safe, efficient, and prevents it from getting stuck in endless updates.
C. Track Past State with useRef
- Sometimes in React, you want to remember if something has already happened, but you don’t want that memory to trigger a re-render. That’s where useRef becomes super useful. It lets you store a value across renders — like a hidden notepad — without updating the UI.
function Login() {
const hasFetched = useRef(false);
useEffect(() => {
if (!hasFetched.current) {
fetchData();
hasFetched.current = true;
}
}, []);
}
Use useRef to store a mutable flag that doesn’t trigger re-renders.
- In this example, we’re using useRef to keep track of whether some data has already been fetched. The flag hasFetched.current starts as false. Inside useEffect, we check this flag. If it’s false, we call the fetchData() function and immediately update the flag to true, making sure this action only happens once. Even if the component re-renders later, it won’t run the fetch again — because the flag silently remembers that it already happened.
Real-Life Examples
1. Preventing Duplicate API Calls
- Let’s say you're on a login page. You only want to fetch the user's details once when the page loads — not every time the component re-renders. Using a ref helps avoid multiple calls and keeps the logic clean.
2. Play Intro or Animation Once
- If your app shows an intro animation, modal, or popup when someone visits a screen — you’d want it to happen only the first time. Using a ref makes this easy to manage without affecting performance.
useRef vs Empty Dependency Array in useEffect — What's the Difference?
- At first glance, using useEffect(() => {...}, []) and using useRef to track things might seem similar — both are often used to make sure something runs only once. But they serve different purposes, and understanding the difference is important to write better React code.
Empty Dependency Array ([]) in useEffect
- This tells React to run the effect only once when the component mounts — like saying:
- "Do this one time when the page loads, and never again."
Why Use useRef Then?
- useRef gives you a persistent memory that doesn’t reset between renders, and doesn’t trigger re-renders when updated.
- It’s perfect for:
- • Remembering if a function has already been called.
In Simple Words:
- • useEffect(..., []) = “Do this only once when the component mounts.”
- • useRef() = “Remember this thing across renders — silently, without affecting the UI.”
What we learn :
- With useRef, we learn how to store values that stay the same across re-renders without causing any UI updates. It’s perfect for tracking actions that should only happen once — like fetching data, showing a popup, or logging an event. Unlike useState, updating a ref doesn’t trigger re-renders, making it a quiet and powerful tool to manage logic behind the scenes.
D. Extract into Custom Hooks
-
As your React components grow, they can start to feel messy if too much logic is packed inside them. This is especially true when you're repeating similar useEffect patterns — like adding delays, timers, or listeners. That’s where custom hooks come in. They let you extract reusable logic into separate functions, making your components cleaner, easier to read, and simpler to maintain.
-
Abstract complex logic into a custom hook:
function useDebouncedEffect(fn, deps, delay) {
useEffect(() => {
const timer = setTimeout(fn, delay);
return () => clearTimeout(timer);
3
}, [...deps, delay]);
}
Keeps components clean and logic reusable.
- In this example, we created a custom hook called useDebouncedEffect. It allows us to delay a function call by a certain amount of time — useful when you're waiting for the user to stop typing before making an API call (a technique called debouncing). Instead of writing this timeout logic in every component, we move it into a hook and just pass in the function, delay, and dependencies. This keeps the component code short, focused, and reusable.
Real-Life Examples
-
1. Search Input with Debounce
-
Let’s say you're building a search bar that sends requests as the user types. Without debouncing, it might send 10 requests for 10 keystrokes. Using a custom hook like useDebouncedEffect, you can wait until the user finishes typing and then send only one request — improving performance and saving bandwidth.
-
2. Delayed Actions (e.g., Auto-Save)
-
Imagine you're making a notes app and want to auto-save changes after the user stops typing. Instead of using timers in every component, you can use a custom debounced hook to trigger save actions smoothly and avoid frequent updates.
-
What We Learn
-
With custom hooks, we learn how to separate reusable logic from component code. This helps keep components clean, organized, and easier to manage. In this case, using a hook for debounced effects makes it simple to delay actions and avoid writing repetitive useEffect + setTimeout logic everywhere.
4. Quick Cheatsheet: Effect Patterns
Goal | Dependency Array |
---|---|
Run only on mount | [] |
Run when foo changes | [foo] |
Run on mount & when foo changes | [, foo] |
Avoid infinite loops | Don’t modify foo in effect |
Debounce or throttle calls | [...deps, delay] |
5. Debugging Tips
- • Console-Log Dependencies: Log each dep before and after the effect to see why it re-ran.
- • React DevTools Profiler: Spot unexpected re-renders.
- • ESLint Plugin (``): Enforces exhaustive deps but also warns about missing or over-including values.
- • Break Complex Components: Build small, testable hooks first, then compose.
Conclusion
- useEffect is one of React’s most powerful hooks—but it demands a shift in thinking: UI as a function of state and effects as side reactions to that state. Once you internalize the model, you’ll write hooks with confidence, avoid infinite loops, and build cleaner, more predictable components.
Have you run into wild useEffect bugs? Drop a comment or DM me on LinkedIn ( @Ullas m Mugalolli ) with your story - might feature it in a follow-up post!
Thanks for reading! If this helped you, consider sharing it so more React devs can finally slay their side-effect demons.
✍️ About the Author
- Ullas M. Mugalolli – Full Stack Developer | Tech Explorer | Problem Solver
- A passionate full stack developer who enjoys building seamless digital experiences from backend logic to polished frontend interfaces.

Connect with Ullas M Mugalolli
Enjoyed my thoughts on Full Stack Dev? Follow my journey and connect with me on my networks:
HAKUNA MATATA 😁, Ullas M Signing off👋
Related Articles
Subscribe to My Newsletter
Stay updated with my latest articles, projects, and exclusive content.
By subscribing, you agree to our Privacy Policy. You can unsubscribe at any time.