Skip to main content
RapidDev - Software Development Agency
lovable-issues

Avoiding Stale State Updates in Lovable React Hooks

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.

Book a free consultation
4.9Clutch rating
600+Happy partners
17+Countries served
190+Team members
Advanced10 min read~15 minAll Lovable projects (React 18+)March 2026RapidDev Engineering Team
TL;DR

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 clicks

This 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 exceeded

A 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

1

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.

Before
typescript
const [count, setCount] = useState(0);
// Bug: rapid clicks all use the same stale 'count' value
const handleClick = () => {
setCount(count + 1);
};
// Bug: both increments use the same 'count', so only +1 happens
const handleDoubleIncrement = () => {
setCount(count + 1);
setCount(count + 1);
};
After
typescript
const [count, setCount] = useState(0);
// Fixed: functional update always gets the latest value
const handleClick = () => {
setCount((prev) => prev + 1);
};
// Fixed: each update builds on the previous one, so +2 happens
const 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.

2

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.

Before
typescript
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 mount
After
typescript
const [count, setCount] = useState(0);
const countRef = useRef(count);
// Keep the ref in sync with the latest state
useEffect(() => {
countRef.current = count;
}, [count]);
// Now the interval reads the ref, which is always current
useEffect(() => {
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.

3

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.

Before
typescript
const [searchTerm, setSearchTerm] = useState("");
const [results, setResults] = useState([]);
// Bug: effect never re-runs because searchTerm is missing from deps
useEffect(() => {
fetchResults(searchTerm).then(setResults);
}, []); // Should include searchTerm
After
typescript
const [searchTerm, setSearchTerm] = useState("");
const [results, setResults] = useState([]);
// Fixed: effect re-runs whenever searchTerm changes
useEffect(() => {
// Prevent race conditions with cleanup flag
let cancelled = false;
fetchResults(searchTerm).then((data) => {
if (!cancelled) {
setResults(data);
}
});
return () => { cancelled = true; };
}, [searchTerm]); // searchTerm in dependency array

Expected result: Search results update whenever the search term changes. No stale results from previous searches appear.

4

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.

Before
typescript
const [items, setItems] = useState([]);
// Bug: handler captures the initial empty array
const handleAddItem = useCallback((newItem) => {
setItems([...items, newItem]);
}, []); // Missing 'items' dependency
After
typescript
const [items, setItems] = useState([]);
// Fixed: functional update does not need 'items' in deps
const handleAddItem = useCallback((newItem) => {
setItems((prev) => [...prev, newItem]);
}, []); // Empty deps is correct because we use functional update

Expected result: Adding items works correctly every time. The callback uses the latest items array through the functional update.

Complete code example

src/components/StaleStateFreeCounter.tsx
1import { useState, useEffect, useRef, useCallback } from "react";
2import { Button } from "@/components/ui/button";
3
4const StaleStateFreeCounter = () => {
5 const [count, setCount] = useState(0);
6 const [log, setLog] = useState<string[]>([]);
7 const countRef = useRef(count);
8
9 // Keep ref in sync with latest state
10 useEffect(() => {
11 countRef.current = count;
12 }, [count]);
13
14 // Functional update — always correct, even with rapid clicks
15 const increment = useCallback(() => {
16 setCount((prev) => prev + 1);
17 }, []);
18
19 // Timer that reads the ref for the latest value
20 useEffect(() => {
21 const interval = setInterval(() => {
22 setLog((prev) => [
23 ...prev.slice(-4),
24 `Count at ${new Date().toLocaleTimeString()}: ${countRef.current}`,
25 ]);
26 }, 2000);
27
28 return () => clearInterval(interval);
29 }, []);
30
31 // Async handler with functional update
32 const delayedIncrement = useCallback(() => {
33 setTimeout(() => {
34 // Functional update ensures we get the latest count
35 setCount((prev) => prev + 1);
36 }, 2000);
37 }, []);
38
39 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 2s
46 </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};
57
58export 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.

ChatGPT Prompt

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

Lovable Prompt

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.

RapidDev

Talk to an Expert

Our team has built 600+ apps. Get personalized help with your issue.

Book a free consultation

Need help with your Lovable project?

Our experts have built 600+ apps and can solve your issue fast. Book a free consultation — no strings attached.

Book a free consultation

We put the rapid in RapidDev

Need a dedicated strategic tech and growth partner? Discover what RapidDev can do for your business! Book a call with our team to schedule a free, no-obligation consultation. We'll discuss your project and provide a custom quote at no cost.