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.

Youzan Coder
Youzan Coder
Youzan Coder
Why Does React Hooks Show Stale State? Unraveling Closures and Dependency Arrays

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> changes

3. 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

useRef

holds 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.

Original Source

Signed-in readers can open the original source through BestHub's protected redirect.

Sign in to view source
Republication Notice

This article has been distilled and summarized from source material, then republished for learning and reference. If you believe it infringes your rights, please contactadmin@besthub.devand we will review it promptly.

State ManagementReacthooksuseReduceruseEffectuseCallbackfunctional components
Youzan Coder
Written by

Youzan Coder

Official Youzan tech channel, delivering technical insights and occasional daily updates from the Youzan tech team.

0 followers
Reader feedback

How this landed with the community

Sign in to like

Rate this article

Was this worth your time?

Sign in to rate
Discussion

0 Comments

Thoughtful readers leave field notes, pushback, and hard-won operational detail here.