Why Does React Hooks Show Stale State? Unraveling Closures and Dependency Arrays
This article explores why React Hooks can display stale state values, comparing functional and class components, explaining closure behavior, dependency‑array mechanics, proper data‑fetching patterns, and when to replace useState with useReducer to avoid common pitfalls.
1. Functional vs. Class Components – Closure Behavior
In a functional component each render creates a new closure. State variables captured inside asynchronous callbacks retain the values from the render during which the callback was created. In a class component the callback accesses this.state, which reflects the latest state because the class instance persists across renders.
function Demo() {
const [num, setNum] = useState(0);
const handleClick = () => {
setTimeout(() => {
alert(num); // captures <strong>num</strong> from this render
}, 3000);
};
// UI omitted – focus on logic
}
class Demo extends Component {
state = { num: 0 };
handleClick = () => {
setTimeout(() => {
alert(this.state.num); // always latest value
}, 3000);
};
}The difference stems from the fact that each functional render creates a fresh num constant, while a class component keeps a single mutable this.state object.
2. How Dependency Arrays Work
Hooks such as useCallback, useEffect and useMemo cache the supplied function or value until one of the dependencies changes. An empty dependency array means the function never updates, leading to stale closures.
function Demo() {
const [num, setNum] = useState(0);
const handleClick = useCallback(() => {
setNum(num + 1); // <strong>num</strong> captured from first render
}, []); // ❌ stale – always adds 1 to initial value
}Fix by listing all external values:
const handleClick = useCallback(() => {
setNum(num + 1);
}, [num]); // ✅ updates when <code>num</code> changes3. Fetching Data with Hooks
Never call an async function that depends on props or state inside useEffect with an empty dependency array, because the effect will capture stale values.
function Demo({ query }) {
const [list, setList] = useState([]);
useEffect(() => {
const fetchData = async () => {
const res = await axios(`/getList?query=${query}`);
setList(res);
};
fetchData();
}, [query]); // ✅ re‑run when <code>query</code> changes
}When multiple asynchronous dependencies exist, split the logic into separate effects:
function Demo({ query }) {
const [id, setId] = useState();
const [list, setList] = useState([]);
// fetch id once
useEffect(() => {
const fetchId = async () => {
const res = await axios('/getId');
setId(res);
};
fetchId();
}, []);
// fetch list when both <code>id</code> and <code>query</code> are ready
useEffect(() => {
if (!id) return;
const fetchData = async () => {
const res = await axios(`/getList?id=${id}&query=${query}`);
setList(res);
};
fetchData();
}, [id, query]);
}4. Replacing useState with useReducer
When state updates depend on the previous state or involve complex logic, useReducer provides a stable dispatch function and eliminates the need to list the state array as a dependency.
const initialList = [];
function reducer(state, action) {
switch (action.type) {
case 'increment':
return [...state, ...action.payload];
default:
throw new Error('unknown action');
}
}
function Demo() {
const [list, dispatch] = useReducer(reducer, initialList);
const fetchData = useCallback(async () => {
const res = await axios('/getList');
dispatch({ type: 'increment', payload: res });
}, []); // <code>dispatch</code> never changes
useEffect(() => {
fetchData();
}, [fetchData]);
// UI omitted – focus on logic
}Because dispatch is stable, the effect depends only on fetchData, avoiding infinite‑loop scenarios that arise when the state itself is listed as a dependency.
5. When to Use useRef
useRefholds a mutable value that survives across renders without triggering re‑renders. Typical uses include storing the latest query string, timer identifiers, or any value that must be read inside callbacks without causing a new render.
function Demo({ query }) {
const latestQuery = useRef(query);
useEffect(() => {
latestQuery.current = query; // update ref on each render
}, [query]);
const handleClick = () => {
// use the most recent query without re‑rendering
console.log('Current query:', latestQuery.current);
};
}Understanding how closures capture state and how dependency arrays control memoization is essential for writing reliable React Hook code.
Signed-in readers can open the original source through BestHub's protected redirect.
This article has been distilled and summarized from source material, then republished for learning and reference. If you believe it infringes your rights, please contactand we will review it promptly.
Youzan Coder
Official Youzan tech channel, delivering technical insights and occasional daily updates from the Youzan tech team.
How this landed with the community
Was this worth your time?
0 Comments
Thoughtful readers leave field notes, pushback, and hard-won operational detail here.
