React Functional Component's state gets reset with setTimeout - reactjs

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}
</>
);
};

Some knowledge should be known first: Why am I seeing stale props or state inside my function?
You have two options:
Use Functional updates:
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

How to correctly use Hooks in React?

I am new to React, and I have to build a timeout mechanism for a page. I used react-idle-timer, with some help found on the Internet. However, when I try to access the page, I get a Minified React error #321, in which it tells me that I used hooks incorrectly.
Can you please take a look on the following code and point me in the right direction? Thanks
import React from "react"
import NavBar from "./Navbar"
import "../styles/Upload.css"
import LinearProgressWithLabel from "./LinearProgressWithLabel"
import axios from "axios"
import Logout from "./Logout"
import { useIdleTimer } from 'react-idle-timer'
import { format } from 'date-fns'
export default function Upload() {
const [selectedFile, setSelectedFile] = React.useState();
const [progress, setProgress] = React.useState(0);
const timeout = 3000;
const [remaining, setRemaining] = React.useState(timeout);
const [elapsed, setElapsed] = React.useState(0);
const [lastActive, setLastActive] = React.useState(+new Date());
const [isIdle, setIsIdle] = React.useState(false);
const handleOnActive = () => setIsIdle(false);
const handleOnIdle = () => setIsIdle(true);
const {
reset,
pause,
resume,
getRemainingTime,
getLastActiveTime,
getElapsedTime
} = useIdleTimer({
timeout,
onActive: handleOnActive,
onIdle: handleOnIdle
});
const handleReset = () => reset();
const handlePause = () => pause();
const handleResume = () => resume();
React.useEffect(() => {
setRemaining(getRemainingTime())
setLastActive(getLastActiveTime())
setElapsed(getElapsedTime())
setInterval(() => {
setRemaining(getRemainingTime())
setLastActive(getLastActiveTime())
setElapsed(getElapsedTime())
}, 1000)
}, []);
function changeHandler(event) {
setSelectedFile(event.target.files[0])
};
function handleSubmission() {
if (selectedFile) {
var reader = new FileReader()
reader.readAsArrayBuffer(selectedFile);
reader.onload = () => {
sendFileData(selectedFile.name, new Uint8Array(reader.result), 4096)
};
}
};
function sendFileData(name, data, chunkSize) {
function sendChunk(offset) {
var chunk = data.subarray(offset, offset + chunkSize) || ''
var opts = { method: 'POST', body: chunk }
var url = '/api/uploaddb?offset=' + offset + '&name=' + encodeURIComponent(name)
setProgress(offset / data.length * 100)
fetch(url, opts).then(() => {
if (chunk.length > 0) {
sendChunk(offset + chunk.length)
}
else {
axios.post('/api/uploaddb/done', { name })
.then(setProgress(100))
.catch(e => console.log(e));
}
})
}
sendChunk(0);
};
return (
<div>
<NavBar />
<div>
<div>
<h1>Timeout: {timeout}ms</h1>
<h1>Time Remaining: {remaining}</h1>
<h1>Time Elapsed: {elapsed}</h1>
<h1>Last Active: {format(lastActive, 'MM-dd-yyyy HH:MM:ss.SSS')}</h1>
<h1>Idle: {isIdle.toString()}</h1>
</div>
<div>
<button onClick={handleReset}>RESET</button>
<button onClick={handlePause}>PAUSE</button>
<button onClick={handleResume}>RESUME</button>
</div>
</div>
<h1>Upload</h1>
<input type="file" name="file" onChange={changeHandler} />
{!selectedFile ? <p className="upload--progressBar">Select a file</p> : <LinearProgressWithLabel className="upload--progressBar" variant="determinate" value={progress} />}
<br />
<div>
<button disabled={!selectedFile} onClick={handleSubmission}>Submit</button>
</div>
</div>
)
}
Well, in this case, you should avoid setting states inside the useEffect function, because this causes an infinite loop. Everytime you set a state value, your component is meant to render again, so if you put states setters inside a useEffect function it will cause an infinite loop, because useEffect function executes once before rendering component.
As an alternative you can set your states values outside your useEffect and then put your states inside the useEffect array param. The states inside this array will be "listened" by useEffect, when these states change, useEffect triggers.
Something like this:
React.useEffect(() => {
}, [state1, state2, state3]);
state anti-pattern
You are using a state anti-pattern. Read about Single Source Of Truth in the React Docs.
react-idle-timer provides getRemainingTime, getLastActiveTime and getElapsedTime
They should not be copied to the state of your component
They are not functions
getRemainingTime(), getLastActiveTime(), or getElapsedTime() are incorrect
To fix each:
getRemainingTime should not be stored in state of its own
Remove const [remaining, setRemaining] = useState(timeout)
Remove setRemaining(getRemainingTime) both places in useEffect
Change <h1>Time Remaining: {remaining}</h1>
To <h1>Time Remaining: {getRemainingTime}</h1>
The same is true for lastActive.
getLastActive should be be stored in state of its own
Remove const [lastActive, setLastActive] = React.useState(+new Date())
Remove setLastActive(getLastActiveTime()) both places in useEffect
Change <h1>Last Active: {format(lastActive, 'MM-dd-yyyy HH:MM:ss.SSS')}</h1>
To <h1>Last Active: {format(getLastActive, 'MM-dd-yyyy HH:MM:ss.SSS')}</h1>
And the same is true for elapsed.
getElapsedTime should be be stored in state of its own
Remove const [elapsed, setElapsed] = React.useState(+new Date())
Remove setElapsed(getElapsedTime()) both places in useEffect
Change <h1>Time Elapsed: {elapsed}</h1>
To <h1>Time Elapsed: {getElapsedTime}</h1>
remove useEffect
Now your useEffect is empty and it can be removed entirely.
unnecessary function wrappers
useIdleTimer provides reset, pause, and resume. You do not need to redefine what is already defined. This is similar to the anti-pattern above.
Remove const handleReset = () => reset()
Change <button onClick={handleReset}>RESET</button>
To <button onClick={reset}>RESET</button>
Remove const handlePause = () => pause()
Change <button onClick={handlePause}>PAUSE</button>
To <button onClick={pause}>PAUSE</button>
Remove const handleResume = () => resume()
Change <button onClick={handleResume}>RESUME</button>
To <button onClick={resume}>RESUME</button>
avoid local state
timeout should be declared as a prop of the Upload component
Remove const timeout = 3000
Change function Upload() ...
To function Upload({ timeout = 3000 }) ...
To change timeout, you can pass a prop to the component
<Upload timeout={5000} />
<Upload timeout={10000} />
use the provided example
Read Hook Usage in the react-idle-timer docs. Start there and work your way up.
import React from 'react'
import { useIdleTimer } from 'react-idle-timer'
import App from './App'
export default function (props) {
const handleOnIdle = event => {
console.log('user is idle', event)
console.log('last active', getLastActiveTime())
}
const handleOnActive = event => {
console.log('user is active', event)
console.log('time remaining', getRemainingTime())
}
const handleOnAction = event => {
console.log('user did something', event)
}
const { getRemainingTime, getLastActiveTime } = useIdleTimer({
timeout: 1000 * 60 * 15,
onIdle: handleOnIdle,
onActive: handleOnActive,
onAction: handleOnAction,
debounce: 500
})
return (
<div>
{/* your app here */}
</div>
)
}

useState wouldn't work asynchronously (React)

Building a recording app on React + Type Script. I tried to set state with getting stream, and it seems to be successfully gotten on console. But it couldn't be set up on record target stream.
const [recordTargetStream, setRecordTargetStream] = useState<MediaStream>()
// click request permissions
const requestPermissions = useCallback(async() => {
const stream = await window.navigator.mediaDevices.getUserMedia({audio: true, video: true})
// stream is successfully gotten
setRecordTargetStream(stream)
}, [])
const startRecording = useCallback(() => {
console.log('start recording', recordTargetStream)
// record target stream is undefined
...
return (
<>
<button onClick={() => requestPermissions()}>Request permissions</button>
<button onClick={() => startRecording()}>Start recording</button>
</>
)
You should use the recordTargetStream state as useCallback hook's dependencies.
useCallback will return a memoized version of the callback that only changes if one of the dependencies has changed.
every value referenced inside the callback should also appear in the dependencies array.
index.tsx:
import React, { useCallback, useState } from 'react'
export default function MyComponent() {
const [recordTargetStream, setRecordTargetStream] = useState<MediaStream>()
const requestPermissions = useCallback(async() => {
const stream = await window.navigator.mediaDevices.getUserMedia({audio: true, video: true})
setRecordTargetStream(stream)
}, [])
const startRecording = useCallback(() => {
console.log('start recording', recordTargetStream)
}, [recordTargetStream])
return (
<>
<button onClick={() => requestPermissions()}>Request permissions</button>
<button onClick={() => startRecording()}>Start recording</button>
</>
)
}
index.test.tsx:
import {fireEvent, render, screen, act} from '#testing-library/react';
import React from 'react';
import MyComponent from './';
describe('69354798', () => {
test('should pass', async () => {
const mockMediaDevices = {
getUserMedia: jest.fn().mockResolvedValue('test stream')
}
Object.defineProperty(window.navigator, 'mediaDevices', {
value: mockMediaDevices
})
render(<MyComponent/>)
await act(async () => {
fireEvent.click(screen.getByText(/Request permissions/))
})
fireEvent.click(screen.getByText(/Start recording/))
})
})
test result:
PASS stackoverflow/69354798/index.test.tsx
69354798
✓ should pass (54 ms)
console.log
start recording test stream
at stackoverflow/69354798/index.tsx:12:13
Test Suites: 1 passed, 1 total
Tests: 1 passed, 1 total
Snapshots: 0 total
Time: 3.211 s

I would expect this useRef and useEffect combo to fail...why does it succeed?

I would expect this useEffect to fail on the first render, since I would assume the innerCarouselRef.current would be undefined on the first render and it makes a call to getBoundingClientRect. Why does it work/why is the innerCarouselRef.current defined when the useEffect runs?
import React from 'react';
import { debounce } from 'lodash';
export default function Carousel({ RenderComponent }) {
const [innerCarouselWidth, setInnerCarouselWidth] = React.useState(0);
const [itemWidth, setItemWidth] = React.useState(0);
const innerCarouselRef = useRef();
const itemRef = useRef();
const content = data.map((el, i) => {
return (
<div key={`item-${i}`} ref={i === 0 ? itemRef : undefined}>
<RenderComponent {...el} />
</div>
);
});
useEffect(() => {
const getElementWidths = () => {
setInnerCarouselWidth(innerCarouselRef.current.getBoundingClientRect().width); // why doesn't this call to getBoundingClientRect() break?
setItemWidth(itemRef.current.getBoundingClientRect().width);
};
getElementWidths();
const debouncedListener = debounce(getElementWidths, 500);
window.addEventListener('resize', debouncedListener);
return () => window.removeEventListener('resize', debouncedListener);
}, []);
return (
<div className="inner-carousel" ref={innerCarouselRef}>
{content}
</div>
)
}
React runs the effects after it has updated the DOM (we typically want it to work that way). In your case, the effect runs after the component has mounted and so innerCarouselRef.current is set.
I would recommend reading the useEffect docs to gain a better understanding.

Jest testing hook state update with setTimeout

I'm trying to test unmount of a self-destructing component with a click handler. The click handler updates useState using a setTimeout.
However, my test fails whereas I'm expecting it to pass. I tried using jest mock timers such as advanceTimersByTime() but it does not work. If I call setState outside setTimeout, the test passes.
component.js
const DangerMsg = ({ duration, onAnimDurationEnd, children }) => {
const [isVisible, setVisible] = useState(false);
const [sectionClass, setSectionClass] = useState(classes.container);
function handleAnimation() {
setSectionClass(classes.containerAnimated);
let timer = setTimeout(() => {
setVisible(false);
}, 300);
return clearTimeout(timer);
}
useEffect(() => {
let timer1;
let timer2;
function animate() {
if (onAnimDurationEnd) {
setSectionClass(classes.containerAnimated);
timer2 = setTimeout(() => {
setVisible(false);
}, 300);
} else {
setVisible(false);
}
}
if (children) {
setVisible(true);
}
if (duration) {
timer1 = setTimeout(() => {
animate();
}, duration);
}
return () => {
clearTimeout(timer1);
clearTimeout(timer2);
};
}, [children, duration, onAnimDurationEnd]);
return (
<>
{isVisible ? (
<section className={sectionClass} data-test="danger-msg">
<div className={classes.inner}>
{children}
<button
className={classes.btn}
onClick={() => handleAnimation()}
data-test="btn"
>
×
</button>
</div>
</section>
) : null}
</>
);
};
export default DangerMsg;
test.js
it("should NOT render on click", async () => {
jest.useFakeTimers();
const { useState } = jest.requireActual("react");
useStateMock.mockImplementation(useState);
// useEffect not called on shallow
component = mount(
<DangerMsg>
<div></div>
</DangerMsg>
);
const btn = findByTestAttr(component, "btn");
btn.simulate("click");
jest.advanceTimersByTime(400);
const wrapper = findByTestAttr(component, "danger-msg");
expect(wrapper).toHaveLength(0);
});
Note, I'm mocking useState implementation with actual because in other tests I used custom useState mock.
Not using Enzyme but testing-library/react instead so it's a partial solution. The following test is passing as expected:
test("display loader after 1 second", () => {
jest.useFakeTimers(); // mock timers
const { queryByRole } = render(
<AssetsMap {...defaultProps} isLoading={true} />
);
act(() => {
jest.runAllTimers(); // trigger setTimeout
});
const loader = queryByRole("progressbar");
expect(loader).toBeTruthy();
});
I directly run timers but advancing by time should give similar results.

Counter increment on setInterval

import React from 'react';
import {Plugins} from '#capacitor/core';
import {useState, useEffect} from 'react';
import {db} from './Firebase';
const Maps = () => {
const [lat, setLat] = useState(0);
const [long, setLong] = useState(0);
const [count, setCount] = useState (0);
const Counter = () => {
setCount(count + 1)
console.log(count)
}
const Location = () => {
Plugins.Geolocation.getCurrentPosition().then(
result => setLat ( result.coords.latitude)
)
Plugins.Geolocation.getCurrentPosition().then(
result => setLong (result.coords.longitude)
)
}
const interval = () => {
setInterval (() =>
{
Location();
Counter();
}, 5000 );
}
return (
<div>
<div>
<button onClick = {interval}>
Get Location
</button>
</div>
<div>
{long}
</div>
<div>
{lat}
</div>
</div>
)
}
export default Maps;
I'm trying to get the counter to increment on every iteration of setInterval, through the counter function, but when I log count, it does not increment and always remains as 0.
I've tried running setCount itself within setInterval without any success, it still does not increment count.
Its a stale closure. Change to this setCount(prevCount => prevCount + 1).
Using the updater form of set state like above, you can guarantee that you will be using the most recent value of state.
You can think of it as count in your function being a snapshot of what its value was when the setInterval was declared. This will stop your updates from appearing to work.
In addition, setting state is async, so the console.log(count) will most likely not reflect the new value. Log in an effect or outside the function body to see the updated value each render.
A note about your implementation:
You are creating a setInterval each time the button is clicked. This could lead to some interesting side-effects if clicked more than once. If you click the button twice for example, you will have two setIntervals running every 5 seconds.
In addition to #BrianThompson answer. Try this to avoid innecessary rerenders
import React from 'react';
import {Plugins} from '#capacitor/core';
import {useState, useEffect} from 'react';
import {db} from './Firebase';
const Maps = () => {
const [state, setState] = useState({
latLng:{lat:0,lng:0},
counter: 0
})
const interval = useRef()
//Use camelCase for methods
const location = () => {
Plugins.Geolocation.getCurrentPosition().then(
result => setState ( ({counter}) => {
counter = counter+1
console.log(counter)
return ({
latLng: {
lat: result.coords.latitude,
lng: result.coords.longitude
},
counter
})
})
)
}
const startInterval = () => {
if(interval.current) return;
interval.current = setInterval (() => {
location();
}, 5000 );
}
const stopInterval = () ={
clearInterval(interval.current)
interval.current = null
}
useEffect(()=>{
//Because interval is causing state updates, remember to clear interval when component will unmount
return stopInterval
},[])
return (
<div>
<div>
<button onClick = {startInterval}>
Get Location
</button>
</div>
<div>
{state.latLng.lng}
</div>
<div>
{state.latLng.lat}
</div>
</div>
)
}

Resources