I'm trying to understand the new React hooks and their use cases.
My goal is a single component that counts up and also every x tick counts another counter.
I have achieved it using useEffect and useState, with two main problems:
1. A memory leak when the component unmounts before the timeout gets called (when navigating using react-router)
2. The component renders twice on every tick because useEffect and useState both trigger the render.
I think the solution will be something with useRef or useMemo but I haven't figured it out yet.
My current component (with typescript):
import React from "react";
const Component: React.FC = () => {
const [trigger, setTrigger] = React.useState(0);
const [timer, setTimer] = React.useState({ cycle: 0, count: 0 });
let refTimer = React.useRef({ cycle: 0, count: 0 });
// useRef
// React.useEffect(() => {
// setInterval(() => {
// console.log("tick");
// if (refTimer.current.count % 2 === 0) {
// refTimer.current.cycle++;
// setTimer(refTimer.current);
// }
// refTimer.current.count++;
// setTimer(refTimer.current);
// // console.log(timer);
// }, 1000);
// }, []);
// useState
React.useEffect(() => {
console.log("effect tick");
setTimeout(() => {
console.log("tick");
const count = timer.count + 1;
if (count % 2 === 0) {
const cycle = timer.cycle + 1;
setTimer({ ...timer, count, cycle });
return;
}
setTimer({ ...timer, count });
}, 1000);
}, [timer]);
return (
<div>
<br />
<br />
<br />
<br /> Playground:
<div>Count: {timer.count}</div>
<div>Cycle: {timer.cycle}</div>
<button type="button" onClick={(): void => setTrigger(trigger + 1)}>
Trigger Count: {trigger}
</button>
</div>
);
};
export default Component;
As I said, like this I have the mentioned two problems. I can remove the useEffect entirely that would fix the double render but when I click the Trigger Button the ticks will stack up which is worse than double renders.
The commented useRef part is what I have tried but it somehow doesn't work.
I appreciate all the help!
Edit:
A third minor problem is that like this the counter runs only with setTimeout which will trigger another setTimeout, so if that process takes some time it won't really be an exact interval.
So my goal is an interval that runs in a separate process (I'd say inside a useEffect) what will cause a rerender on every tick and won't stack up on each call or when something else triggers a rerender.
You can fix the memory leak mentioned in #1.
React.useEffect(() => {
console.log("effect tick", timer);
// .. 👇 get the timeout ID to clear on unmount
const id = setTimeout(() => {
console.log(`tick id=${id}`, timer);
const count = timer.count + 1;
if (count % 2 === 0) {
const cycle = timer.cycle + 1;
setTimer({ ...timer, count, cycle });
return;
}
setTimer({ ...timer, count });
}, 1000);
// ... 👇 Clean up here with the ID on unmount
return () => clearTimeout(id);
}, [timer]);
Regarding #2 double render, would you be more specific?
Before/After cleaning up in useEffect above, I am unable to figure out what you mean as the current console logs seem to work as expected.
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
I'm beginner in React and I want create a component that is in constant motion, incrementing its style property transform:translate, but the increment isn't what I expected. What's happening?
This is my code:
function Point() {
const [countX, setCountX] = useState(0);
const [countY, setCountY] = useState(0);
setInterval(() => {
setCountX((count) => count + 1);
setCountY((count) => count + 1);
console.log(countX+":"+countY);
}, 500);
const styles = {
transform: `translate(${countX}px,${countY}px)`,
};
return <PointStyle style={styles} />;
}
And the output in the console from console.log(countX+":"+countY) is as follows:
0:0
1:1
0:0
2:2
1:1
4:4
0:0
5:5
2:2
7:7
1:1
9:9
4:4
10:10
11:11
0:0
12:12
5:5
14:14
2:2
7:7
Every time your component re-renders, it is creating an interval. Also, the previous intervals are not getting cleaned up. So, all your intervals are updating the count at the same time.
You need to move the setInterval into a useEffect and also cleanup the interval.
useEffect(() => {
const interval = setInterval(() => {
setCountX((count) => count + 1);
setCountY((count) => count + 1);
console.log(countX+":"+countY);
}, 500);
return () => clearInterval(interval)
}, [])
Each time you call one of your setState functions (setCountX, or setCountY) the component will end up being rerendered. This will cause the setInterval to be called repeatedly creating many intervals running out of sync all attempting to update the X and Y position of your point.
To fix this you need to put your setInterval code inside of a useEffect.
const Point = () => {
const [countX, setCountX] = useState(0);
const [countY, setCountY] = useState(0);
// The below useEffect will be triggered on component mount and
// whenever a value in the dependency array changes (an empty
// dependency array is only on mount, such as this example).
//
// The function returned from the useEffect is a clean up function
// that will be run whenever a value in the dependency array changes
// or when the component unmounts
useEffect(() => {
// Create interval to change point X and Y, ensuring to store
// the returned ID so that the interval can be cleared later.
const intervalId = setInterval(() => {
setCountX((count) => count + 1);
setCountY((count) => count + 1);
console.log(countX + ":" + countY);
}, 500);
// clean up function created in the current closure will
// keep hold of previously stored intervalId value.
return () => {
// Clear interval (in this example only run on component unmount)
clearInterval(intervalId);
}
}, [] /* Dependency Array */)
const styles = {
transform: `translate(${countX}px,${countY}px)`,
};
return <PointStyle style={styles} />;
}
I am trying to set up an image carousel that loops through 3 images when you mouseover a div. I'm having trouble trying to figure out how to reset the loop after it reaches the third image. I need to reset the setInterval so it starts again and continuously loops through the images when you are hovering over the div. Then when you mouseout of the div, the loop needs to stop and reset to the initial state of 0. Here is the Code Sandbox:
https://codesandbox.io/s/pedantic-lake-wn3s7
import React, { useState, useEffect } from "react";
import { images } from "./Data";
import "./styles.css";
export default function App() {
let timer;
const [count, setCount] = useState(0);
const updateCount = () => {
timer = setInterval(() => {
setCount((prevCount) => prevCount + 1);
}, 1000);
if (count === 3) clearInterval(timer);
};
const origCount = () => {
clearInterval(timer);
setCount((count) => 0);
};
return (
<div className="App">
<div className="title">Image Rotate</div>
<div onMouseOver={updateCount} onMouseOut={origCount}>
<img src={images[count].source} alt={images.name} />
<p>count is: {count}</p>
</div>
</div>
);
}
Anything involving timers/intervals is an excellent candidate for useEffect, because we can easily register a clear action in the same place that we set the timer using effects with cleanup. This avoids the common pitfalls of forgetting to clear an interval, e.g. when the component unmounts, or losing track of interval handles. Try something like the following:
import React, { useState, useEffect } from "react";
import { images } from "./Data";
import "./styles.css";
export default function App() {
const [count, setCount] = useState(0);
const [mousedOver, setMousedOver] = useState(false);
useEffect(() => {
// set an interval timer if we are currently moused over
if (mousedOver) {
const timer = setInterval(() => {
// cycle prevCount using mod instead of checking for hard-coded length
setCount((prevCount) => (prevCount + 1) % images.length);
}, 1000);
// automatically clear timer the next time this effect is fired or
// the component is unmounted
return () => clearInterval(timer);
} else {
// otherwise (not moused over), reset the counter
setCount(0);
}
// the dependency on mousedOver means that this effect is fired
// every time mousedOver changes
}, [mousedOver]);
return (
<div className="App">
<div className="title">Image Rotate</div>
<div
// just set mousedOver here instead of calling update/origCount
onMouseOver={() => setMousedOver(true)}
onMouseOut={() => setMousedOver(false)}
>
<img src={images[count].source} alt={images.name} />
<p>count is: {count}</p>
</div>
</div>
);
}
As to why your code didn't work, a few things:
You meant to say if (count === 2) ..., not count === 3. Even better would be to use the length of the images array instead of hardcoding it
Moreover, the value of count was stale inside of the closure, i.e. after you updated it using setCount, the old value of count was still captured inside of updateCount. This is actually the reason to use functional state updates, which you did when you said e.g. setCount((prevCount) => prevCount + 1)
You would have needed to loop the count inside the interval, not clear the interval on mouse over. If you think through the logic of it carefully, this should hopefully be obvious
In general in react, using a function local variable like timer is not going to do what you expect. Always use state and effects, and in rarer cases (not this one), some of the other hooks like refs
I believe that setInterval does not work well with function components. Since callback accesses variables through closure, it's really easy to shoot own foot and either get timer callback referring to stale values or even have multiple intervals running concurrently. Not telling you cannot overcome that, but using setTimeout is much much much easier to use
useEffect(() => {
if(state === 3) return;
const timerId = setTimeout(() => setState(old => old + 1), 5000);
return () => clearTimeout(timerId);
}, [state]);
Maybe in this particular case cleanup(clearTimeout) is not required, but for example if user is able to switch images manually, we'd like to delay next auto-change.
The timer reference is reset each render cycle, store it in a React ref so it persists.
The initial count state is closed over in interval callback scope.
There are only 3 images so the last slide will be index 2, not 3. You should compare against the length of the array instead of hard coding it.
You can just compute the image index by taking the modulus of count state by the array length.
Code:
export default function App() {
const timerRef = useRef();
const [count, setCount] = useState(0);
// clear any running intervals when unmounting
useEffect(() => () => clearInterval(timerRef.current), []);
const updateCount = () => {
timerRef.current = setInterval(() => {
setCount((count) => count + 1);
}, 1000);
};
const origCount = () => {
clearInterval(timerRef.current);
setCount(0);
};
return (
<div className="App">
<div className="title">Image Rotate</div>
<div onMouseOver={updateCount} onMouseOut={origCount}>
<img
src={images[count % images.length].source} // <-- computed index to cycle
alt={images.name}
/>
<p>count is: {count}</p>
</div>
</div>
);
}
Your setCount should use a condition to check to see if it should go back to the start:
setCount((prevCount) => prevCount === images.length - 1 ? 0 : prevCount + 1);
This will do setCount(0) if we're on the last image—otherwise, it will do setCount(prevCount + 1).
A faster (and potentially more readable) way of doing this would be:
setCount((prevCount) => (prevCount + 1) % images.length);
It keeps showing the error message that it is an infinite loop. I am only beggining to learn React, and this is a Clicker game. How do I change my code to make the setInterval work. Thank you.(BTW I do not want any other changes to the code that won't affect the setInterval function. And yes, I have used setInterval in many projects already and it worked out fine.)
import "./styles.css";
export default function App() {
let [num, setNum] = useState(0);
let [add, setAdd] = useState(1);
let [numC, setNumC] = useState(0);
let [numCP, setNumCP] = useState(10);
let [numW, setNumW] = useState(0);
let [numWP, setNumWP] = useState(20)
setInterval(setNum(num+=numW),3000);
const click = () => {
setNum((num += add));
};
const clicker = () => {
if (num >= numCP) {
setNumC((numC += 1));
setNum((num -= numCP));
setNumCP((numCP += 5));
setAdd((add += 1));
}
};
const worker = () => {
if (num >= numWP) {
setNumW((numW += 1));
setNum((num -= numWP));
setNumWP((numWP += 10));
}
};
return (
<div className="App">
<h1>Clicker Game</h1>
<div>
{num}
<button onClick={click}>Click</button>
</div>
<p />
<div>
{numC}
<button onClick={clicker}>Buy({numCP})</button>
</div>
<div>
{numW}
<button onClick={worker}>Buy({numWP})</button>
</div>
</div>
);
}```
There are a couple of issues.
First you are immediately calling the setNum when you should be passing a callback to be executed when the interval is passed.
So setInterval(() => setNum(num+=numW),3000);
But now you have the second issue, each time the component is re-rendered you will initiate an additional interval. (and it will be re-rendered a lot, at the minimum each time the interval callback is fired)
So you would likely need to use a useEffect, with 0 dependencies so it runs once, if you want to set it and let it run continuously.
useEffect(() => {
setInterval(() => setNum(num += numW), 3000);
}, []);
But now you will encounter yet another issue. The num and numW used in the interval will be locked to the values in the first render of the component.
For the num you can work around it, by using the callback syntax of the setNum
useEffect(() => {
setInterval(() => setNum(currentNum => currentNum += numW), 3000);
}, []);
but numW will never update.
A final tool, is to reset the interval each time the numW or num changes. To do that you will need to return a function from the useEffect that does the clearing.
useEffect(() => {
const interval = setInterval(() => setNum(currentNum => currentNum += numW), 3000);
return () => clearInterval(interval);
}, [numW]);
But this will have the minor issue that the interval is now not constant, since it resets.
Every time one of your state variables changes, the component is re-rendered, i.e. the function App is called again. This will call setInterval over and over again.
You want to look at useEffect to put your setIntervals in.
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>
);
}