Stale state in Lovable hooks happens when event handlers or effects capture an old value of state from a previous render, causing bugs like counters that skip numbers, toggles that revert, and timers that show wrong values. Fix this by using functional setState updates (setState(prev => prev + 1)), useRef for values that need to be current inside closures, and correct dependency arrays in useEffect. This is the definitive guide to stale closures in React hooks.
Why React hooks capture stale values in Lovable-generated code
Every time a React component renders, it creates a new 'snapshot' of all its variables, including state values. Functions defined inside the component (event handlers, callbacks, effects) capture the values from that specific render. If the function runs later — after state has changed — it still sees the old captured value. This is called a stale closure. Lovable's AI often generates code with stale closure bugs because the patterns look correct at first glance. A counter that calls setCount(count + 1) works on the first click but breaks when clicked rapidly because each click captures the same stale count value. The same issue affects timers, debounced handlers, WebSocket callbacks, and any function that runs asynchronously. This is one of the most common and confusing React bugs. It does not produce an error message — the code runs without crashing but produces incorrect results. Understanding when and why closures become stale is essential for building reliable interactive features in Lovable.
- Using setCount(count + 1) instead of setCount(prev => prev + 1) — captures the value at render time instead of using the latest
- useEffect dependency array missing a state variable — the effect keeps using the stale initial value
- Event handlers defined inside the component body that reference state without using functional updates
- setTimeout or setInterval callbacks that capture state at the time the timer was created
- WebSocket or subscription callbacks that were registered with an old state value
Error messages you might see
React Hook useEffect has a missing dependency: 'count'ESLint's exhaustive-deps rule detected that your useEffect reads count but does not include it in the dependency array. This means the effect will always see the initial value of count. Add count to the dependency array or use a ref.
State update not reflecting: counter shows wrong value after rapid clicksThis is not a console error but a visual bug. Rapid clicks call setCount(count + 1) where count is the same stale value for all clicks. Use functional updates: setCount(prev => prev + 1).
Maximum update depth exceededA useEffect that depends on a state variable it also updates creates an infinite loop. Each update triggers the effect, which updates again. Use a ref to break the cycle or restructure the dependency array.
Before you start
- A Lovable project with interactive features that use React hooks (useState, useEffect, useCallback)
- A bug where state values seem outdated, wrong, or lagging behind user actions
- Access to the browser console to verify state values during debugging
How to fix it
Replace direct state references with functional setState updates
Functional updates always receive the latest state value as an argument, eliminating stale closure bugs entirely
Replace direct state references with functional setState updates
Functional updates always receive the latest state value as an argument, eliminating stale closure bugs entirely
Find every place where you call a setState function with a direct reference to the current state variable. Replace it with the functional form that receives the previous value as a parameter. This is the single most impactful fix for stale state bugs. The functional form works correctly even when multiple updates are batched or when the handler runs after a delay.
const [count, setCount] = useState(0);// Bug: rapid clicks all use the same stale 'count' valueconst handleClick = () => { setCount(count + 1);};// Bug: both increments use the same 'count', so only +1 happensconst handleDoubleIncrement = () => { setCount(count + 1); setCount(count + 1);};const [count, setCount] = useState(0);// Fixed: functional update always gets the latest valueconst handleClick = () => { setCount((prev) => prev + 1);};// Fixed: each update builds on the previous one, so +2 happensconst handleDoubleIncrement = () => { setCount((prev) => prev + 1); setCount((prev) => prev + 1);};Expected result: The counter increments correctly on every click, even during rapid clicking. Double increment adds 2 as expected.
Use useRef for values that need to be current inside effects and timers
useRef creates a mutable container that always holds the latest value, bypassing the closure capture problem
Use useRef for values that need to be current inside effects and timers
useRef creates a mutable container that always holds the latest value, bypassing the closure capture problem
When you need to read the latest state value inside a setInterval, setTimeout, or event listener callback, store the value in a ref and update it on every render. The ref's .current property always reflects the most recent value because it is a mutable object, not a captured variable. This pattern is essential for timers and subscription callbacks.
const [count, setCount] = useState(0);// Bug: the interval always sees count as 0 (stale closure)useEffect(() => { const interval = setInterval(() => { console.log("Current count:", count); }, 1000); return () => clearInterval(interval);}, []); // Empty deps means count is captured once at mountconst [count, setCount] = useState(0);const countRef = useRef(count);// Keep the ref in sync with the latest stateuseEffect(() => { countRef.current = count;}, [count]);// Now the interval reads the ref, which is always currentuseEffect(() => { const interval = setInterval(() => { console.log("Current count:", countRef.current); }, 1000); return () => clearInterval(interval);}, []);Expected result: The interval logs the correct current count value every second, even as the user clicks to increment it.
Fix useEffect dependency arrays to avoid stale values
Missing dependencies cause the effect to run with captured values from the initial render instead of current values
Fix useEffect dependency arrays to avoid stale values
Missing dependencies cause the effect to run with captured values from the initial render instead of current values
Review every useEffect in your component. If the effect reads a state variable or prop, that variable must be in the dependency array. If adding the variable causes the effect to run too often, restructure the logic: move the variable into a ref, use functional setState inside the effect, or split the effect into smaller effects with narrower dependencies.
const [searchTerm, setSearchTerm] = useState("");const [results, setResults] = useState([]);// Bug: effect never re-runs because searchTerm is missing from depsuseEffect(() => { fetchResults(searchTerm).then(setResults);}, []); // Should include searchTermconst [searchTerm, setSearchTerm] = useState("");const [results, setResults] = useState([]);// Fixed: effect re-runs whenever searchTerm changesuseEffect(() => { // Prevent race conditions with cleanup flag let cancelled = false; fetchResults(searchTerm).then((data) => { if (!cancelled) { setResults(data); } }); return () => { cancelled = true; };}, [searchTerm]); // searchTerm in dependency arrayExpected result: Search results update whenever the search term changes. No stale results from previous searches appear.
Use useCallback with correct dependencies for memoized handlers
useCallback memoizes a function, but with wrong dependencies it memoizes a function that captures stale values
Use useCallback with correct dependencies for memoized handlers
useCallback memoizes a function, but with wrong dependencies it memoizes a function that captures stale values
If you pass event handlers as props to child components and use useCallback for performance, make sure the dependency array includes every state variable the handler reads. If the handler only uses functional setState, the dependency array can be empty because functional updates do not reference external state. If this involves optimizing callbacks across many components, RapidDev's engineers have solved this exact issue across 600+ projects and can handle it safely.
const [items, setItems] = useState([]);// Bug: handler captures the initial empty arrayconst handleAddItem = useCallback((newItem) => { setItems([...items, newItem]);}, []); // Missing 'items' dependencyconst [items, setItems] = useState([]);// Fixed: functional update does not need 'items' in depsconst handleAddItem = useCallback((newItem) => { setItems((prev) => [...prev, newItem]);}, []); // Empty deps is correct because we use functional updateExpected result: Adding items works correctly every time. The callback uses the latest items array through the functional update.
Complete code example
1import { useState, useEffect, useRef, useCallback } from "react";2import { Button } from "@/components/ui/button";34const StaleStateFreeCounter = () => {5 const [count, setCount] = useState(0);6 const [log, setLog] = useState<string[]>([]);7 const countRef = useRef(count);89 // Keep ref in sync with latest state10 useEffect(() => {11 countRef.current = count;12 }, [count]);1314 // Functional update — always correct, even with rapid clicks15 const increment = useCallback(() => {16 setCount((prev) => prev + 1);17 }, []);1819 // Timer that reads the ref for the latest value20 useEffect(() => {21 const interval = setInterval(() => {22 setLog((prev) => [23 ...prev.slice(-4),24 `Count at ${new Date().toLocaleTimeString()}: ${countRef.current}`,25 ]);26 }, 2000);2728 return () => clearInterval(interval);29 }, []);3031 // Async handler with functional update32 const delayedIncrement = useCallback(() => {33 setTimeout(() => {34 // Functional update ensures we get the latest count35 setCount((prev) => prev + 1);36 }, 2000);37 }, []);3839 return (40 <div className="max-w-md mx-auto space-y-4 p-6">41 <h2 className="text-2xl font-bold">Count: {count}</h2>42 <div className="flex gap-2">43 <Button onClick={increment}>Increment</Button>44 <Button variant="outline" onClick={delayedIncrement}>45 +1 after 2s46 </Button>47 </div>48 <div className="text-sm text-muted-foreground space-y-1">49 <p className="font-medium">Timer log (updates every 2s):</p>50 {log.map((entry, i) => (51 <p key={i}>{entry}</p>52 ))}53 </div>54 </div>55 );56};5758export default StaleStateFreeCounter;Best practices to prevent this
- Always use functional setState (prev => prev + 1) when the new value depends on the previous value
- Use useRef to hold the latest state value when you need to read it inside timers, intervals, or subscription callbacks
- Include every state variable and prop in useEffect dependency arrays — never suppress the exhaustive-deps ESLint rule
- When a useEffect needs to update the same state it depends on, use functional updates to avoid infinite loops
- Use useCallback with functional updates so the callback can have an empty dependency array without stale closures
- Add cleanup functions to useEffect (return () => clearInterval(id)) to prevent stale callbacks from running after unmount
- For async operations, use a cancelled flag in useEffect cleanup to prevent setting state on stale responses
- Test stale state bugs by clicking rapidly — if the result is wrong after fast clicks, you have a stale closure
Still stuck?
Copy one of these prompts to get a personalized, step-by-step explanation.
I have a stale state bug in my Lovable React component. The state value seems outdated or wrong after certain interactions. Here is my component: [paste your component code here] Please: 1. Identify all stale closure issues in this code 2. Fix each one using the appropriate pattern (functional setState, useRef, or corrected dependency arrays) 3. Add cleanup functions to any useEffect that sets up timers or subscriptions 4. Explain which pattern to use in which situation 5. Make sure rapid user interactions (fast clicks, typing) produce correct results
Fix stale state bugs in @src/components/Counter.tsx. Replace all direct state references in setState calls with functional updates (prev => prev + 1). For the setInterval callback that reads the count, add a useRef that stays in sync with count and read countRef.current inside the interval. Add proper cleanup (clearInterval) in the useEffect return. Fix the useEffect dependency array to include all referenced state variables.
Frequently asked questions
What is a stale closure in React hooks?
A stale closure happens when a function captures a state value from a previous render and continues using that outdated value. For example, a setInterval callback created on the first render will always see the state as it was at mount time, not the current value. This causes bugs where displayed values lag behind or skip updates.
When should I use functional setState vs direct setState?
Use functional setState (prev => prev + 1) whenever the new value depends on the previous value. Use direct setState (setValue(newValue)) when setting a completely new value that does not depend on the old one, like setting a form field to the user's input: setValue(e.target.value).
Why does my counter skip numbers when I click fast?
Each click handler captures the count value at the time the handler was created. If you click three times before React re-renders, all three handlers see the same stale count and all compute the same result. Use setCount(prev => prev + 1) so each update builds on the previous one.
How do I read the latest state inside a setInterval?
Store the state value in a useRef and update the ref whenever the state changes. Inside the interval callback, read ref.current instead of the state variable. The ref is a mutable object that always holds the latest value, unlike state which is captured at render time.
Should I suppress the exhaustive-deps ESLint warning?
Almost never. The warning exists to prevent stale closure bugs. If adding a dependency causes unwanted re-runs, restructure the code: use functional setState, move the value to a ref, or split the effect. Only suppress the warning as a last resort with a clear comment explaining why.
What if I can't fix this myself?
Stale closure bugs can be subtle and hard to trace, especially in components with multiple effects and async operations. RapidDev's engineers have debugged stale state issues across 600+ Lovable projects and can identify and fix the root cause quickly.
Talk to an Expert
Our team has built 600+ apps. Get personalized help with your issue.
Book a free consultation