React Functional Component's state gets reset with setTimeout

Viewed 47

Can someone please help me understand why this variable requestCount gets anchored at 0? I expect that it will increment every 3s but instead it doesn't.

const SomeComponent = () => {
  const [requestCount, setRequestCount] = useState(-1);

  const doSomeWork= () => {
    setRequestCount(requestCount + 1);
    setTimeout(doSomeWork, 3000);
  };

  return (
    <>
      <button onClick={doSomeWork}>Click</button>
      {requestCount}
    </>
  );
};
1 Answers

Some knowledge should be known first: Why am I seeing stale props or state inside my function?

You have two options:

  1. Use Functional updates:

  2. Use useCallback with dependencies to create new doSomeWork every time when the requestCount state changes. So that the doSomeWork will read the latest requestCount state instead of stale value. And, you should execute the macro task queued by setTimeout using useEffect() hook with doSomeWork dependency. Because we need to wait for the execution of useCallback to complete and create a new doSomeWork function so that the macro task(doSomeWork) will read the latest requestCount.

The below example shows these two options:

SomeComponent.tsx:

import React, { useCallback, useEffect, useRef } from 'react';
import { useState } from 'react';

export const SomeComponent = () => {
  const [requestCount, setRequestCount] = useState(-1);
  // should execute macro task queued by setTimeout
  const ref = useRef(null);

  // option 1
  // const doSomeWork = () => {
  //   setRequestCount((pre) => pre + 1);
  //   setTimeout(doSomeWork, 3000);
  // };

  // option 2
  const doSomeWork = useCallback(() => {
    setRequestCount(requestCount + 1);
    if (ref.current) return;
    ref.current = true;
  }, [requestCount]);

  useEffect(() => {
    if (ref.current) {
      setTimeout(doSomeWork, 3000);
    }
  }, [ref.current, doSomeWork]);

  return (
    <>
      <button onClick={doSomeWork}>Click</button>
      {requestCount}
    </>
  );
};

Unit test:

import { render, screen, fireEvent, act } from '@testing-library/react';
import React from 'react';
import { SomeComponent } from './SomeComponent';

describe('70297876', () => {
  test('should pass', async () => {
    jest.useFakeTimers();
    const { container } = render(<SomeComponent />);
    expect(container).toMatchInlineSnapshot(`
<div>
  <button>
    Click
  </button>
  -1
</div>
`);
    const button = screen.getByText('Click');
    fireEvent.click(button);
    act(() => {
      jest.advanceTimersByTime(3000);
    });

    expect(container).toMatchInlineSnapshot(`
<div>
  <button>
    Click
  </button>
  1
</div>
`);
    act(() => {
      jest.advanceTimersByTime(3000);
    });
    expect(container).toMatchInlineSnapshot(`
<div>
  <button>
    Click
  </button>
  2
</div>
`);
  });
});

Test result:

 PASS  stackoverflow/70297876/SomeComponent.test.tsx
  70297876
    ✓ should pass (36 ms)

Test Suites: 1 passed, 1 total
Tests:       1 passed, 1 total
Snapshots:   3 passed, 3 total
Time:        3.865 s, estimated 4 s
Related