I'm making a custom error window that pops up in various situations. What I'm struggling with is getting the window to dissapear after 2 seconds.. Just a simple setTimeout to change the popup state to active:false is a bit unreliable because of the way the even loop works (i think?).
So I'm attempting an async/await way of doing it making sure it's always exactly 2 seconds. However the way I have done it below the timing still seems to be very weird, sometimes instant, sometimes 2 seconds.
How do I get my removeErrorMsg function to wait 2 seconds before setting the state?
///// App.js.js ////
const delay = ms => new Promise(resolve => setTimeout(resolve, ms));
export default class App extends Component {
state = {
errorPopup: {
active: false,
message: ''
}
}
removeErrorMsg = async() => {
await delay(2000);
this.setState({errorPopup: {active: false, message: ''}});
}
}
///// ErrorPopup.js ////
import React from 'react'
const ErrorPopup = ({ message, active, removeErrorMsg}) => {
if(active){
removeErrorMsg()
return (
<div className="error-popup">
<p>{message}</p>
</div>
)
} else return <div></div>
}
export default ErrorPopup
You must call the removeErrorMsg inside the ErrorPopup component within a useEffect function. Directly calling it will result in another delay being created which resets the state as soon as any other action in parent component tries to trigger a re-render leading to unexpected behaviours
const ErrorPopup = ({ message, active, removeErrorMsg}) => {
useEffect(() => {
if(active) {
removeErrorMsg()
}
}, [active])
if(active){
return (
<div className="error-popup">
<p>{message}</p>
</div>
)
} else return <div></div>
}
P.S. Although there is no gurantee that the setTimeout will execute immediately at 2sec, more or less it roughly execute around 2sec.
Related
I created a simple countdown component that I would like to test is working correctly with React testing library. Everything works as expected in a browser but when testing the rendered output never advances more than one second regardless of the values used.
Component (custom hook based on Dan Abramovs blog post):
import React, { useEffect, useRef, useState } from "react"
import TimerButton from "../TimerButton/TimerButton"
// custom hook
function useInterval(callback, delay, state) {
const savedCallback = useRef()
// Remember the latest callback.
useEffect(() => {
savedCallback.current = callback
}, [callback])
// Set up the interval.
useEffect(() => {
function tick() {
savedCallback.current()
}
if (state.isOn) {
let id = setInterval(tick, delay)
return () => clearInterval(id)
}
}, [state])
}
const Timer = () => {
const initialState = {
minutes: 25,
seconds: 0,
isOn: false,
}
const [timerData, setTimerData] = useState(initialState)
useInterval(
() => {
if (timerData.seconds === 0) {
if (timerData.minutes === 0) {
setTimerData({ ...timerData, isOn: false })
} else {
setTimerData({
...timerData,
minutes: timerData.minutes - 1,
seconds: 59,
})
}
} else {
setTimerData({ ...timerData, seconds: timerData.seconds - 1 })
}
},
1000,
timerData,
)
const displayedTime = `${
timerData.minutes >= 10 ? timerData.minutes : `0${timerData.minutes}`
}:${timerData.seconds >= 10 ? timerData.seconds : `0${timerData.seconds}`}`
const startTimer = () => {
setTimerData({ ...timerData, isOn: true })
}
const stopTimer = () => {
setTimerData({ ...timerData, isOn: false })
}
const resetTimer = () => {
setTimerData(initialState)
}
return (
<div className="timer__container" data-testid="timerContainer">
<div className="timer__time-display" data-testid="timeDisplayContainer">
{displayedTime}
</div>
<div className="timer__button-wrap">
<TimerButton buttonAction={startTimer} buttonValue="Start" />
<TimerButton buttonAction={stopTimer} buttonValue="Stop" />
<TimerButton buttonAction={resetTimer} buttonValue="Reset" />
</div>
</div>
)
}
export default Timer
And the test (there is a beforeEach and afterEach calling jest.useFakeTimers() and jest.useRealTimers() respectively above):
it("Assert timer counts down by correct interval when starting timer", async () => {
render(<Timer />)
const initialTime = screen.getByText("25:00")
const startTimerButton = screen.getByText("Start")
expect(initialTime).toBeInTheDocument() // This works fine
fireEvent.click(startTimerButton)
act(() => {
jest.advanceTimersByTime(5000)
})
await waitFor(() => expect(screen.getByText("24:55")).toBeInTheDocument())
})
Output of the test:
Unable to find an element with the text: 24:55. This could be because the text is broken up by multiple elements. In this case, you can provide a function for your text matcher to make your matcher more flexible.
Ignored nodes: comments, script, style
<body>
<div>
<div
class="timer__container"
data-testid="timerContainer"
>
<div
class="timer__time-display"
data-testid="timeDisplayContainer"
>
24:59 <--- This value is always the same
</div>
<div
class="timer__button-wrap"
>
{ ... }
</div>
</div>
</body>
Id like to assert that the correct time is being displayed after moving the time forward by different increments, if I can do this then it will allow for a much more comprehensive set of tests.
I have tried using many of the timing methods in Jest including advanceTimersByTime(), runOnlyPendingTimers(), runAllTimers() to no avail, I can never get the time to advance by more than 1 second. I have also added console.log()s in at various points in the useInterval function and can verify that the function is being called a varying number of times depending on how much I try to advance the time by (it gets called numbers of seconds + 1 times as expected).
When logging out the state data I have seen that the timerData state does not update except for the last run through:
callback {"minutes":25,"seconds":0,"isOn":true} // Every time but the last
callback {"minutes":24,"seconds":59,"isOn":true} // The last time only
Im thinking it must be something to do with how Im referencing the state values during update, or how Jests fake timers interact with state updates though I really dont know at this point
So, after a lot of playing and trying many different methods I have a solution for this.
Further investigation into the calling of the useInterval() function called showed that:
the correct lines to update state were being reached, but the component was only re-rendering once after all these loops of the setInterval function despite the number of updates to state that were supposedly triggered (verified using console logs)
altering the time passed to setInterval (i) to be less than 500ms showed that the time was advancing by amounts roughly equivalent to 1 % i
the component only re-renders once for each tick of the setInterval function that occurs under the time passed to jest.advanceTimersByTime()
From this it appears that for each call of jest.advanceTimersByTime() only one iteration of the setInterval function can be moved through, and so the only way I found to move the interval forward by the correct amount of time is to call advanceTimersByTime() repeatedly via a helper function:
const advanceJestTimersByTime = (increment, iterations) => {
for (let i = 0; i < iterations; i++) {
act(() => {
jest.advanceTimersByTime(increment)
})
}
}
Test updated to:
it("Assert timer counts down by correct interval when starting timer", async () => {
render(<Timer />)
const initialTime = screen.getByText("25:00")
const startTimerButton = screen.getByText("Start")
expect(initialTime).toBeInTheDocument()
fireEvent.click(startTimerButton)
advanceJestTimersByTime(1000, 60)
await waitFor(() => expect(screen.getByText("24:00")).toBeInTheDocument())
})
This passes and allows for assertions to be made at any point for the countdown
So I have a component where I have to make an API call to get some data that has IDs that I use for another async API call. My issue is I can't get the async API call to work correctly with updating the state via spread (...) so that the checks in the render can be made for displaying specific stages related to specific content.
FYI: Project is a Headless Drupal/React.
import WidgetButtonMenu from '../WidgetButtonMenu.jsx';
import { WidgetButtonType } from '../../Types/WidgetButtons.tsx';
import { getAllInitaitives, getInitiativeTaxonomyTerm } from '../../API/Initiatives.jsx';
import { useEffect } from 'react';
import { useState } from 'react';
import { stripHTML } from '../../Utilities/CommonCalls.jsx';
import '../../../CSS/Widgets/WidgetInitiativeOverview.css';
import iconAdd from '../../../Icons/Interaction/icon-add.svg';
function WidgetInitiativeOverview(props) {
const [initiatives, setInitiatives] = useState([]);
const [initiativesStages, setInitiativesStage] = useState([]);
// Get all initiatives and data
useEffect(() => {
const stages = [];
const asyncFn = async (initData) => {
await Promise.all(initData.map((initiative, index) => {
getInitiativeTaxonomyTerm(initiative.field_initiative_stage[0].target_id).then((data) => {
stages.push({
initiativeID: initiative.nid[0].value,
stageName: data.name[0].value
});
});
}));
return stages;
}
// Call data
getAllInitaitives().then((data) => {
setInitiatives(data);
asyncFn(data).then((returnStages) => {
setInitiativesStage(returnStages);
})
});
}, []);
useEffect(() => {
console.log('State of stages: ', initiativesStages);
}, [initiativesStages]);
return (
<>
<div className='widget-initiative-overview-container'>
<WidgetButtonMenu type={ WidgetButtonType.DotsMenu } />
{ initiatives.map((initiative, index) => {
return (
<div className='initiative-container' key={ index }>
<div className='top-bar'>
<div className='initiative-stage'>
{ initiativesStages.map((stage, stageIndex) => {
if (stage.initiativeID === initiative.nid[0].value) {
return stage.stageName;
}
}) }
</div>
<button className='btn-add-contributors'><img src={ iconAdd } alt='Add icon.' /></button>
</div>
<div className='initiative-title'>{ initiative.title[0].value } - NID ({ initiative.nid[0].value })</div>
<div className='initiative-description'>{ stripHTML(initiative.field_initiative_description[0].processed) }</div>
</div>
);
}) }
</div>
</>
);
}
export default WidgetInitiativeOverview;
Here's a link for video visualization: https://vimeo.com/743753924. In the video you can see that on page refresh, there is not data within the state but if I modify the code (like putting in a space) and saving it, data populates for half a second and updates correctly within the component.
I've tried using spread to make sure that the state isn't mutated but I'm still learning the ins and outs of React.
The initiatives state works fine but then again that's just 1 API call and then setting the data. The initiativeStages state can use X amount of API calls depending on the amount of initiatives are returned during the first API call.
I don't think the API calls are necessary for this question but I can give reference to them if needed. Again, I think it's just the issue with updating the state.
the function you pass to initData.map() does not return anything, so your await Promise.all() is waiting for an array of Promise.resolve(undefined) to resolve, which happens basically instantly, certainly long before your requests have finished and you had a chance to call stages.push({ ... });
That's why you setInitiativesStage([]) an empty array.
And what you do with const stages = []; and the stages.push() inside of the .then() is an antipattern, because it produces broken code like yours.
that's how I'd write that effect:
useEffect(() => {
// makes the request for a single initiative and transforms the result.
const getInitiative = initiative => getInitiativeTaxonomyTerm(
initiative.field_initiative_stage[0].target_id
).then(data => ({
initiativeID: initiative.nid[0].value,
stageName: data.name[0].value
}))
// Call data
getAllInitaitives()
.then((initiatives) => {
setInitiatives(initiatives);
Promise.all(initiatives.map(getInitiative))
.then(setInitiativesStage);
});
}, []);
this code still has a flaw (imo.) it first updates setInitiatives, then starts to make the API calls for the initiaives themselves, before also updating setInitiativesStage. So there is a (short) period of time when these two states are out of sync. You might want to delay setInitiatives(initiatives); until the other API requests have finished.
getAllInitaitives()
.then(async (initiatives) => {
const initiativesStages = await Promise.all(initiatives.map(getInitiative));
setInitiatives(initiatives);
setInitiativesStage(initiativesStages)
});
why the following react code render three thimes, what is the mechanism of react rendering
import React from 'react';
const Toggle = (props) => {
const [ num ,setNumber ] = React.useState(0)
setTimeout(() => {
setNumber(1)
}, 0)
console.log('render'); // render three times ?
return <button >{ console.log(num) } {num}</button>
};
export default Toggle;
And the following react code render one thimes
import React from 'react';
const Toggle = (props) => {
const [ num ,setNumber ] = React.useState(0)
setTimeout(() => {
setNumber(0) // change
}, 0)
console.log('render'); // render one times ?
return <button >{ console.log(num) } {num}</button>
};
export default Toggle;
As you know, Node is asynchronous javascript and also non-blocking.
So it first execute console.log('render');, after that whrn the setTimeout time comes it execute line by line from setTimeout(() => { inside the setTimeout there is a state change setNumber(1) for this the whole program execute again. This is the reason tou see console.log('render'); executes three times. You can understand better if you increase setTimeout time.
then the reason behind 2nd code console.log('render'); just one is the react virtual dom.
I am trying to build a barebones css transition wrapper in React, where a boolean property controls an HTML class that toggles css properties that are set to transition. For the use case in question, we also want the component to be unmounted (return null) before the entrance transition and after the exit transition.
To do this, I use two boolean state variables: one that controls the mounting and one that control the HTML class. When props.in goes from false to true, I set mounted to true. Now the trick: if the class is set immediate to "in" when it's first rendered, the transition does not occur. We need the component to be rendered with class "out" first and then change the class to "in".
A setTimeout works but is pretty arbitrary and not strictly tied to the React lifecycle. I've found that even a timeout of 10ms can sometimes fail to produce the effect. It's a crapshoot.
I had thought that using useEffect with mounted as the dependency would work because the component would be rendered and the effect would occur after:
useEffect(if (mounted) { () => setClass("in"); }, [mounted]);
(see full code in context below)
but this fails to produce the transition. I believe this is because React batches operations and chooses when to render to the real DOM, and most of the time doesn't do so until after the effect has also occurred.
How can I guarantee that my class value is change only after, but immediately after, the component is rendered after mounted gets set to true?
Simplified React component:
function Transition(props) {
const [inStyle, setInStyle] = useState(props.in);
const [mounted, setMounted] = useState(props.in);
function transitionAfterMount() {
// // This can work if React happens to render after mounted get set but before
// // the effect; but this is inconsistent. How to wait until after render?
setInStyle(true);
// // this works, but is arbitrary, pits UI delay against robustness, and is not
// // tied to the React lifecycle
// setTimeout(() => setInStyle(true), 35);
}
function unmountAfterTransition() {
setTimeout(() => setMounted(false), props.duration);
}
// mount on props.in, or start exit transition on !props.in
useEffect(() => {
props.in ? setMounted(true) : setInStyle(false);
}, [props.in]);
// initiate transition after mount
useEffect(() => {
if (mounted) { transitionAfterMount(); }
}, [mounted]);
// unmount after transition
useEffect(() => {
if (!props.in) { unmountAfterTransition(); }
}, [props.in]);
if (!mounted) { return false; }
return (
<div className={"transition " + inStyle ? "in" : "out"}>
{props.children}
</div>
)
}
Example styles:
.in: {
opacity: 1;
}
.out: {
opacity: 0;
}
.transition {
transition-property: opacity;
transition-duration: 1s;
}
And usage
function Main() {
const [show, setShow] = useState(false);
return (
<>
<div onClick={() => setShow(!show)}>Toggle</div>
<Transition in={show} duration={1000}>
Hello, world.
</Transition>
<div>This helps us see when the above component is unmounted</div>
</>
);
}
Found the solution looking outside of React. Using window.requestAnimationFrame allows an action to be take after the next DOM paint.
function transitionAfterMount() {
// hack: setTimeout(() => setInStyle(true), 35);
// not hack:
window.requestAnimationFrame(() => setInStyle(true));
}
I have the following component defined in my app scaffolded using create-react:
import React, { useState } from 'react';
const Play = props => {
const [currentSecond, setCurrentSecond] = useState(1);
let timer;
const setTimer = () => {
timer = setInterval(() => {
if (currentSecond < props.secondsPerRep) {
setCurrentSecond(() => currentSecond + 1);
}
}, 1000);
}
setTimer();
return (
<div>
<div>
<p>{currentSecond}</p>
</div>
</div>
);
}
export default Play;
And currentSecond is updated every second until it hits the props.secondsPerRep however if I try to start the setInterval from a click handler:
import React, { useState } from 'react';
const Play = props => {
const [currentSecond, setCurrentSecond] = useState(1);
let timer;
const setTimer = () => {
timer = setInterval(() => {
if (currentSecond < props.secondsPerRep) {
setCurrentSecond(() => currentSecond + 1);
}
}, 1000);
}
return (
<div>
<div>
<button onClick={setTimer}>Start</button>
<p>{currentSecond}</p>
</div>
</div>
);
}
export default Play;
Then currentSecond within the setInterval callback always returns to the initial value, i.e. 1.
Any help greeeeeeatly appreciated!
Your problem is this line setCurrentSecond(() => currentSecond + 1); because you are only calling setTimer once, your interval will always be closed over the initial state where currentSecond is 1.
Luckily, you can easily remedy this by accessing the actual current state via the args in the function you pass to setCurrentSecond like setCurrentSecond(actualCurrentSecond => actualCurrentSecond + 1)
Also, you want to be very careful arbitrarily defining intervals in the body of functional components like that because they won't be cleared properly, like if you were to click the button again, it would start another interval and not clear up the previous one.
I'd recommend checking out this blog post because it would answer any questions you have about intervals + hooks: https://overreacted.io/making-setinterval-declarative-with-react-hooks/
https://overreacted.io/making-setinterval-declarative-with-react-hooks/ is a great post to look at and learn more about what's going on. The React useState hook doesn't play nice with setInterval because it only gets the value of the hook in the first render, then keeps reusing that value rather than the updated value from future renders.
In that post, Dan Abramov gives an example custom hook to make intervals work in React that you could use. That would make your code look more like this. Note that we have to change how we trigger the timer to start with another state variable.
const Play = props => {
const [currentSecond, setCurrentSecond] = React.useState(1);
const [isRunning, setIsRunning] = React.useState(false);
useInterval(() => {
if (currentSecond < props.secondsPerRep) {
setCurrentSecond(currentSecond + 1);
}
}, isRunning ? 1000 : null);
return (
<div>
<div>
<button onClick={() => setIsRunning(true)}>Start</button>
<p>{currentSecond}</p>
</div>
</div>
);
}
I went ahead and put an example codepen together for your use case if you want to play around with it and see how it works.
https://codepen.io/BastionTheDev/pen/XWbvboX
That is because you're code is closing over the currentSecond value from the render before you clicked on the button. That is javascript does not know about re-renders and hooks. You do want to set this up slightly differently.
import React, { useState, useRef, useEffect } from 'react';
const Play = ({ secondsPerRep }) => {
const secondsPassed = useRef(1)
const [currentSecond, setCurrentSecond] = useState(1);
const [timerStarted, setTimerStarted] = useState(false)
useEffect(() => {
let timer;
if(timerStarted) {
timer = setInterval(() => {
if (secondsPassed.current < secondsPerRep) {
secondsPassed.current =+ 1
setCurrentSecond(secondsPassed.current)
}
}, 1000);
}
return () => void clearInterval(timer)
}, [timerStarted])
return (
<div>
<div>
<button onClick={() => setTimerStarted(!timerStarted)}>
{timerStarted ? Stop : Start}
</button>
<p>{currentSecond}</p>
</div>
</div>
);
}
export default Play;
Why do you need a ref and the state? If you would only have the state the cleanup method of the effect would run every time you update your state. Therefore, you don't want your state to influence your effect. You can achieve this by using the ref to count the seconds. Changes to the ref won't run the effect or clean it up.
However, you also need the state because you want your component to re-render once your condition is met. But since the updater methods for the state (i.e. setCurrentSecond) are constant they also don't influence the effect.
Last but not least I've decoupled setting up the interval from your counting logic. I've done this with an extra state that switches between true and false. So when you click your button the state switches to true, the effect is run and everything is set up. If you're components unmounts, or you stop the timer, or the secondsPerRep prop changes the old interval is cleared and a new one is set up.
Hope that helps!
Try that. The problem was that you're not using the state that is received by the setCurrentSecond function and the function setInterval don't see the state changing.
const Play = props => {
const [currentSecond, setCurrentSecond] = useState(1);
const [timer, setTimer] = useState();
const onClick = () => {
setTimer(setInterval(() => {
setCurrentSecond((state) => {
if (state < props.secondsPerRep) {
return state + 1;
}
return state;
});
}, 1000));
}
return (
<div>
<div>
<button onClick={onClick} disabled={timer}>Start</button>
<p>{currentSecond}</p>
</div>
</div>
);
}