goal: create a Clock component which calls a callback method at regular intervals, but whose speed can be controlled.
Tricky part: do not reset the clock timer immediately when the speed changes, but at the next "tick" check the desired speed and if it has changes, reset the current interval and schedule a new one. This is needed to keep the clock ticket at a smooth pace when changing the speed.
I thought that passing a function getDelay that returns the delay (instead of the value of the delay itself) would make this work, but it doesn't.
If I let useEffect track the getDelay function it will reset when the delay changes. If it don't track getDelay the speed will not change while the clock is running.
import React, { useEffect, useRef } from "react";
type Callback = () => void;
function useInterval(tickCallback: Callback, getDelay: () => number, isPlaying: boolean) {
const refDelay = useRef<number>(getDelay());
useEffect(() => {
let id: number;
console.log(`run useEffects`);
function tick() {
const newDelay = getDelay();
if (tickCallback) {
console.log(`newDelay: ${newDelay}`);
tickCallback();
if (newDelay !== refDelay.current) {
// if delay has changed, clear and schedule new interval
console.log(`delay changed. was ${refDelay.current} now is ${newDelay}`)
refDelay.current = newDelay;
clear();
playAndSchedule(newDelay);
}
}
}
/** clear interval, if any */
function clear() {
if (id) {
console.log(`clear ${id}`)
clearInterval(id);
}
}
/** schedule interval and return cleanup function */
function playAndSchedule(delay: number) {
if (isPlaying) {
id = window.setInterval(tick, delay);
console.log(`schedule delay id ${id}. ms ${delay}`)
return clear
}
}
return playAndSchedule(refDelay.current);
},
// with getDelay here the clock is reset as soon as the delay value changes
[isPlaying, getDelay]);
}
type ClockProps = {
/** true if playing */
isPlaying: boolean;
/** return the current notes per minute */
getNpm: () => number;
/** function to be executed every tick */
callback: () => void;
}
export function Clock(props: ClockProps) {
const { isPlaying, getNpm, callback } = props;
useInterval(
callback,
() => {
console.log(`compute delay for npm ${getNpm()}`);
return 60_000 / getNpm();
},
isPlaying);
return (<React.Fragment />);
}
you can use something like this:
import React, { useCallback, useEffect, useMemo, useRef } from 'react';
function useInterval(tickCallback: () => void, delay: number, isPlaying: boolean) {
const timeout = useRef<any>(null);
const savedDelay = useRef(delay);
const savedTickCallback = useRef(tickCallback);
useEffect(() => {
savedDelay.current = delay;
}, [delay])
useEffect(() => {
savedTickCallback.current = tickCallback;
}, [tickCallback])
const startTimeout = useCallback(() => {
const delay = savedDelay.current;
console.log('next delay', delay);
timeout.current = setTimeout(() => {
console.log('delay done', delay);
savedTickCallback.current();
startTimeout();
}, savedDelay.current);
}, []);
useEffect(() => {
if (isPlaying) {
if (!timeout.current) {
startTimeout();
}
} else {
if (timeout.current) {
clearTimeout(timeout.current);
}
}
},
[isPlaying, startTimeout],
);
}
type ClockProps = {
/** true if playing */
isPlaying: boolean;
/** return the current notes per minute */
getNpm: () => number;
/** function to be executed every tick */
callback: () => void;
}
export const Clock: React.FC<ClockProps> = ({ isPlaying, getNpm, callback }) => {
const delay = useMemo(() => {
console.log(`compute delay for npm ${getNpm()}`);
return 60_000 / getNpm();
}, [getNpm]);
useInterval(callback, delay, isPlaying);
return null;
};
Related
This question already has answers here:
clearInterval not working in React Application using functional component
(5 answers)
Closed 12 days ago.
First time using clearInterval() looking at other examples and the interval docs this appears to be the way to stop an interval. Not sure what I am missing.
The intention is to kill the timer when the currentStop prop updates.
import React, { useEffect, useState } from 'react';
type Props = {
stopNumber: number;
currentStop: number;
};
const timerComponent = ({ stopNumber, currentStop }: Props) => {
let interval: NodeJS.Timer;
// Update elapsed on a timer
useEffect(() => {
if (stopNumber === currentStop) {
interval = setInterval(() => {
console.log('timer is running');
}, 3000);
// Clear interval on unmount
return () => clearInterval(interval);
}
}, []);
// Clear timers that were running
useEffect(() => {
if (stopNumber !== currentStop) {
clearInterval(interval);
}
}, [currentStop]);
};
Store the intervalId on a ref instead
const timerComponent = ({ stopNumber, currentStop }: Props) => {
const intervalRef = useRef({
intervalId: 0
})
// Update elapsed on a timer
useEffect(() => {
if (stopNumber === currentStop) {
intervalRef.current.intervalId = setInterval(() => {
console.log('timer is running');
}, 3000);
// Clear interval on unmount
return () => clearInterval(intervalRef.current.intervalId);
}
}, []);
// Clear timers that were running
useEffect(() => {
if (stopNumber !== currentStop) {
clearInterval(intervalRef.current.intervalId);
}
}, [currentStop]);
};
Use a ref to store the interval id instead.
let interval = useRef();
// to start the setInterval:
interval.current = setInterval(...);
// to stop the setInterval:
clearInterval(interval.current);
From what I can tell, the timer being called in a different scope.. how can I accomplish a function to stop the timer? Going a bit crazy here, thank you for any help.
const SomeComponent = ({ isPlaying }) => {
let timer = null;
React.useEffect(() => {
if (isPlaying) {
startTimer();
}
},[isPlaying]);
const startTimer = () => {
timer = setInterval(() => {
console.log('tick');
}, 1000);
};
const stopTimer = () => {
console.log('stopping timer: ', timer); // shows null, instead of giving the timerId to stop properly
clearInterval(timer);
};
The timer variable will "reset" each time your component is re-rendered. Even if it holds your timer, a re-render will set its value to null again.
You could either move out of the component scope, or use useRef to keep the variable through re-renders:
const SomeComponent = ({ isPlaying }) => {
const timer = React.useRef(null);
React.useEffect(() => {
if (isPlaying) {
startTimer();
}
return () => clearInterval(timer.current);
}, [isPlaying]);
const startTimer = () => {
timer.current = setInterval(() => {
console.log('tick');
}, 1000);
};
const stopTimer = () => {
clearInterval(timer.current);
};
Note that I also force a clearInterval by using a return inside the useEffect. This way the component will automatically "clean up" when it unmounts. I also changed timer to be a constant.
I am trying to clear the useInterval function once the joke array has 5 jokes as objects. However, I'm not sure what I'm doing wrong. For full code: https://codesandbox.io/s/asynchronous-test-mp2fq?file=/AutoComplete.js
const [joke, setJoke] = React.useState([]);
function useInterval(callback, delay) {
const savedCallback = useRef();
let id;
useEffect(() => {
savedCallback.current = callback;
if (joke.length === 5) {
console.log("5 STORED AND CLEARED INTERVAL");
return () => clearInterval(id);
}
});
useEffect(() => {
function tick() {
savedCallback.current();
}
id = setInterval(tick, delay);
return () => clearInterval(id);
}, [delay]);
}
useInterval(() => {
// setJoke(joke);
axios.get("https://api.chucknorris.io/jokes/random").then((res) => {
setJoke(joke.concat(res.data.value));
console.log("JOKE: ", joke);
});
console.log("Every 5 seconds");
}, 5000);
Use a ref to store the interval id, to preserve it between re-renders. When the length is 5, call clearInterval instead of returning a clean function that would be called when the component unmounts.
In addition, make the hook agnostic of the actual stop condition by supplying a stop function, and calling it whenever the component re-renders.
function useInterval(callback, delay, stop) {
const savedCallback = useRef();
const interval = useRef();
useEffect(() => {
savedCallback.current = callback;
if (stop?.()) { // call stop to check if you need to clear the interval
clearInterval(interval.current); // call clearInterval
}
});
useEffect(() => {
function tick() {
savedCallback.current();
}
interval.current = setInterval(tick, delay); // set the current interval id to the ref
return () => clearInterval(interval.current);
}, [delay]);
}
const Example () => {
const [joke, setJoke] = React.useState([]);
useInterval(() => {
// setJoke(joke);
axios.get("https://api.chucknorris.io/jokes/random").then((res) => {
setJoke(joke.concat(res.data.value));
console.log("JOKE: ", joke);
});
console.log("Every 5 seconds");
}, 5000, () => joke.length > 4);
return (
...
);
};
Try adding in callback to your dependency array
useEffect(() => {
savedCallback.current = callback;
if (joke.length === 5) {
console.log("5 STORED AND CLEARED INTERVAL");
return () => clearInterval(id);
}
},[callback]);
I am trying to make a pomodoro app and the count down clock will keep toggle the breaking state when the time is up, the breaking state will indicate whether you're currently working (breaking === false) or you're taking a break (breaking === true).
However, the console shows that the setBreaking are keep looping, resulting in error. I've tried to pass in an anonymous function with prevState => !prevState, error still occur. Any advice?
Here are the excerpt:
function Clock() {
const [minute, setMinute] = useState(parseInt(remainingTime/60));
const [second, setSecond] = useState(padZero(remainingTime%60));
const [breaking, setBreaking] = useState(false);
function padZero(num) {
return num.toString().padStart(2,0);
}
function countDown() {
useInterval(() => {
setRemainingTime(remainingTime-1);
}, 1000);
}
if (remainingTime === 0) {
setBreaking(!breaking) // keep looping
setCountDown(false);
setRemainingTime(breakMinute*60)
}
if (countingDown === true) {
countDown();
} else {
console.log('Timer stopped!');
}
return <h1>{minute}:{second}</h1>
};
You are not supposed to put subscriptions, timers... inside the main body. Instead, you need to put your code and start the countdown into a useEffect(..., []) hook.
By not using hooks, your code will be executed everytime you are trying to render the component, and sometimes it's kind of random...
function Clock() {
const [minute, setMinute] = useState(parseInt(remainingTime/60));
const [second, setSecond] = useState(padZero(remainingTime%60));
const [breaking, setBreaking] = useState(false);
React.useEffect(() => {
// Your code here
}, []);
return <h1>{minute}:{second}</h1>
};
Here you go with a solution using useEffect
function Clock() {
const [minute, setMinute] = useState(parseInt(remainingTime/60));
const [second, setSecond] = useState(padZero(remainingTime%60));
const [breaking, setBreaking] = useState(false);
function padZero(num) {
return num.toString().padStart(2,0);
}
function countDown() {
useInterval(() => {
setRemainingTime(remainingTime-1);
}, 1000);
}
React.useEffect(() => {
if (remainingTime === 0) {
setBreaking(!breaking);
setCountDown(false);
setRemainingTime(breakMinute*60)
}
}, [remainingTime]);
React.useEffect(() => {
if (countingDown) {
countDown();
} else {
console.log('Timer stopped!');
}
}, [countingDown]);
return <h1>{minute}:{second}</h1>
};
Using useEffect you can watch the state variable and take action based on that.
I'm coding a countdown with Expo.
I'm using functional components, so my state is handled via React's useState hook.
let [state, setState] = useState({
secondsLeft: 25,
started: false,
});
If I press a Button it does fire this function:
let onPressHandler = (): void => {
if(!state.started) {
setState({...state, started: true});
setInterval(()=> {
setState({...state, secondsLeft: state.secondsLeft - 1});
console.log(state.secondsLeft);
}, 1000);
}
}
Problem is that each 1000 ms Expo refreshes the app instead of updating the state.
Can you help me, please?
It updates the state, but it uses stale state to do so. The state variable in your setInterval callback will never change once the interval is started.
Instead, use the setter form of the state update function, so you're always working with the then-current state:
let onPressHandler = (): void => {
if(!state.started) {
setState({...state, started: true});
setInterval(()=> {
setState(currentState => {
const newState = {...currentState, secondsLeft: currentState.secondsLeft - 1};
console.log(newState.secondsLeft);
return newState;
});
}, 1000);
}
};
It's more concise without the console.log:
let onPressHandler = (): void => {
if(!state.started) {
setState({...state, started: true});
setInterval(()=> {
setState(currentState => {...currentState, secondsLeft: currentState.secondsLeft - 1});
}, 1000);
}
};
On a separate note: If you have state items that you update independently from one another, best practice is to use separate state variables for them. Also, since they're constant within your function, it's best to declare them as const. Like this:
const [secondsLeft, setSecondsLeft] = useState(25);
const [started, setStarted] = useState(false);
// ...
let onPressHandler = (): void => {
if(!started) {
setStarted(true);
setInterval(()=> {
setSecondsLeft(seconds => seconds - 1);
}, 1000);
}
};
Also, since you can't rely on setInterval to be at all precise, I suggest storing your stop time ("now" plus 25 seconds) and recalculating how many seconds are left each time:
let onPressHandler = (): void => {
const stopTime = Date.now() + (DURATION * 1000);
setStarted(true);
setSecondsLeft(DURATION);
const timer = setInterval(()=> {
const left = Math.round((stopTime - Date.now()) / 1000);
if (left <= 0) {
clearInterval(timer);
setStarted(false);
} else {
setSecondsLeft(left);
}
}, 1000);
};
Live Example (with logic for stopping):
const {useState} = React;
const Example = () => {
const DURATION = 25; // seconds
const [started, setStarted] = useState(false);
const [secondsLeft, setSecondsLeft] = useState(0);
if (started) {
return <div>Seconds left: {secondsLeft}</div>;
}
let onPressHandler = ()/*: void*/ => {
const stopTime = Date.now() + (DURATION * 1000);
setStarted(true);
setSecondsLeft(DURATION);
const timer = setInterval(()=> {
const left = Math.round((stopTime - Date.now()) / 1000);
if (left <= 0) {
clearInterval(timer);
setStarted(false);
} else {
setSecondsLeft(left);
}
}, 1000);
};
return (
<input
type="button"
onClick={onPressHandler}
value="Start"
/>
);
};
ReactDOM.render(<Example />, document.getElementById("root"));
<div id="root"></div>
<script src="https://cdnjs.cloudflare.com/ajax/libs/react/16.12.0/umd/react.production.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/react-dom/16.12.0/umd/react-dom.production.min.js"></script>