React counter getting out of sync when tab is not focused - javascript

I’m building with React a timer that multiple people will see at the same time. I’ve noticed that, once the timer has been opened by two persons on different tabs, if the user looking at the timer changes of tab and comes back, the timer gets out of sync (dragged a few seconds).
Inside the timer component I’m providing a duration prop and I have a secondsToCountdown internal state that gets updated every second with an interval inside the component. The interval is something like this (using hooks btw):
const [secondsToCountdown, setSecondsToCountdown] = useState(duration * 60);
const tick = () => {
setSecondsToCountdown(secondsToCountdown - 1);
};
useInterval(
() => {
tick();
},
running ? 1000 : null
);
I’m guessing that for some reason the interval stops or runs slowly when the component is out of view. Is there a workaround for this? Am I doing something wrong here? Is the only solution just to use something along the lines of the visibilitychange event?

I think you can use requestAnimationFrame and instead counting down from a number you can compare the current datetime with the target datetime
I made a sample sandbox here https://codesandbox.io/s/0v3xo8p4yp.
import React, { useState } from "react";
import ReactDOM from "react-dom";
const countDown = 20 * 60 * 1000; //20 minutes
const defaultTargetDate = new Date().getTime() + countDown;
const App = () => {
const [targetDate, setTargetDate] = useState(new Date(defaultTargetDate));
const [remainingSeconds, setRemainingSeconds] = useState(countDown / 1000);
const countItDown = () =>
requestAnimationFrame(() => {
const diff = Math.floor((targetDate - new Date().getTime()) / 1000);
setRemainingSeconds(diff);
if (diff > 0) {
countItDown();
}
});
countItDown();
return <div>{remainingSeconds} sec</div>;
};

Related

Getting always initial value of state after updating the state from the local storage

I'm using react-timer-hook package in my next.js project to display a timer like you can see in the screenshot below:
Now the issue is I want to persist this timer elapsed time into the local storage and whenever the page reloads manually then I need the Timer to start from that specific elapsed time that I'm trying to get from the local storage, but whenever I reload the page manually then Timer starts from the initial state's value. below are the codes:
Timer Component
function Timer({ seconds, minutes, hours }) {
return (
<Typography variant="h5" fontWeight={'bold'} component={'div'}>
<span>{String(hours).padStart(2, '0')}</span>:
<span>{String(minutes).padStart(2, '0')}</span>:
<span>{String(seconds).padStart(2, '0')}</span>
</Typography>
);
}
I'm adding 3600 seconds into expiryTimestamp i.e., current date and time to get one hour of Timer.
let expiryTimestamp = new Date();
expiryTimestamp.setSeconds(expiryTimestamp.getSeconds() + 3600);
Aslo I'm using another state with same 3600 seconds initial value
const [elapsed, setElapsed] = useState(3600);
I'm using useEffect and decrementing the elapsed value on every second into local storage.
useEffect(() => {
const interval = setInterval(() => {
localStorage.setItem('elapsed', JSON.stringify(elapsed--));
}, 1000);
return () => clearInterval(interval);
}, [elapsed]);
Now I'm getting the elapsed value from the local storage
useEffect(() => {
const elapsed = localStorage.getItem('elapsed');
if (elapsed) {
setElapsed(JSON.parse(elapsed));
}
}, []);
Again I'm using another variable to create current date and time + elapsed value
let currentTime = new Date();
currentTime.setSeconds(currentTime.getSeconds() + elapsed);
Finally I'm passing the currentTime in useTimer hook
const { seconds, minutes, hours } = useTimer({
expiryTimestamp: currentTime,
onExpire: handleForm,
});
Elapsed time is properly storing in the local storage, but still Timer starts from 3600 seconds.
We can use expiryTimestamp value of use timer as function to initiate its value. Check the following component
import { useEffect } from 'react';
import { useTimer } from 'react-timer-hook';
export default function Timer() {
const { seconds, minutes, hours } = useTimer({
expiryTimestamp: () => {
/** determine the expiry time stamp value all at once */
const time = new Date(),
elapsedTime = Number(window.localStorage.getItem('elapsed')) || 3600;
time.setSeconds(time.getSeconds() + elapsedTime);
return time;
},
onExpire: () => alert('expired')
});
/** update elapsed value in local storage */
useEffect(() => {
const elapsedSeconds = hours * 60 * 60 + minutes * 60 + seconds;
window.localStorage.setItem('elapsed', elapsedSeconds);
}, [seconds]);
return (
<div>
<span>{String(hours).padStart(2, '0')}</span>:
<span>{String(minutes).padStart(2, '0')}</span>:
<span>{String(seconds).padStart(2, '0')}</span>
</div>
);
}
If you're using this component in next.js. You should use dynamic import with ssr disabled. otherwise you'll receive an error because SSR doesn't recognize the window.localStorage api. Check below
import dynamic from 'next/dynamic';
import React from 'react';
const Timer = dynamic(() => import('./timer'), { ssr: false });
export default function Index = () => {
return <Timer />;
};
It looks like your first useEffect hook is overwriting the value in local storage before you set your state from what's stored there.
Hooks fire in the order in which they are defined within a component, so your component is doing:
Set state to 3600
UseEffect where we set the local storage
UseEffect where we read local storage (whose value has just been overwritten to the default 3600) and set the state to that
You could try checking the local storage value and starting your interval in the same hook.
I'd first start by starting my default state with undefined
const [elapsed, setElapsed] = useState();
This lets me control the starting of the timer entirely within the useEffect
useEffect(() => {
// make sure your first action is to get the elapsed time in local storage
const storedElapsed = localStorage.getItem('elapsed');
// work out if this is the initial load or not
if (!elapsed) {
// component state is not yet set, so use either local storage or the default
const newElapsedState = JSON.parse(storedElapsed) || 3600;
setElapsed(newElapsedState);
} else {
// we now know that state is set correctly, so can ignore local storage and start the timer
const interval = setInterval(() => {
localStorage.setItem('elapsed', JSON.stringify(elapsed--));
}, 1000);
return () => clearInterval(interval);
}
}, [elapsed]);
You will need to handle your first render being undefined, but after that your flow should be consistent as you have control over what is being set in what order.
You can control this by the order in which you define your useEffects, but I personally find this way a bit clearer as the logic is grouped together.

How to prevent a web timer to throttle on inactive tab(Chrome)?

I'm building simple web timer to calculate time working on tasks. Say, I have a 5 min timer and when time is up, I receive a notification.
I build it storing the time in state. But when the tab (Chrome) is inactive, because I'm actually working, the time throttle and gather some delay. (Chrome does that to conserve memory).
import React, { useEffect } from "react";
import { useFinishSession } from "../../hooks/useFinishSession";
const TimerIsActive = ({ setTimer, timerState, isActive }) => {
const { seconds, minutes, initialSeconds, initialMinutes } = timerState;
const { decreaseSeconds, decreaseMinutes, setSessionMinutes, setSessionSeconds } = setTimer;
const { finishSession, loading, error } = useFinishSession();
useEffect(() => {
let interval: NodeJS.Timer;
// reduce seconds and minutes by 1
if (isActive) {
interval = setInterval(() => {
if (seconds > 0) {
decreaseSeconds();
}
// when timer is finised, restart
if (seconds === 0 && minutes === 0) {
finishSession();
setSessionMinutes(initialMinutes);
setSessionSeconds(initialSeconds);
clearInterval(interval);
}
if (seconds === 0) {
decreaseMinutes();
}
}, 1000);
return () => clearInterval(interval);
}
}, [isActive, seconds, minutes]);
return null;
};
export default TimerIsActive;
How I go around that?
Here'are the solutions I'm contemplating:
Instead of storing the time in state, I will store the "start time" using a new Date() and calculate the difference every second to get an accurate time. (difference in seconds) --> my main concern with this approach is that the time only "correct" when the tab regain "focus". Hence, the notification won't fire "in time" when the timer is over.
Using service worker to calculate the time in the background.
What is the best options?
Similar question:
Countdown timer 'delays' when tab is inactive?

How to run to if statements simultaneously in javascript?

I am developing an employee login system that calculates the total time employee was logged in including the break times etc so I have two components 1. which keeps calculating the time until the employee is logged in 2. which calculates the total time of break. So in this situtation I do not want the login time to stop when the break is punched instead I want that break and login time should be running together
const [isLoggedIn, setLoggedIn] = useState(true)
const [isBreakPunched, setBreakPunch] = useState(true)
const [breakTime, setBreakTime] = useState(0);
const [loginTime, setLoginTime] = useState(0);
useEffect(() => {
if (isLoggedIn) {
const interval = setInterval(() => {
setLoginTime(onlineTime + 1);
}, 1000);
return () => clearInterval(interval);
}
if (isBreakPunched){
const interval = setInterval(() =>{
setBreakTime(auxTime +1);
},1000);
return ()=> clearInterval(interval);
}
});
When this runs, the loginTime is only running because the first condition is for login time indeed so to run the breakTime I have to stop the loginTime. This code is treating both as if..else.. loop
My query is that I want to run these both simultaneously when condition is true of anyone, they should be independent from each other
setInterval( () => {
if (isLoggedIn) {
setLoginTime(onlineTime + 1);
}
if (isBreakPunched) {
setBreakTime(auxTime +1);
}
}, 1000);
This interval checks if someone is logged in to set the login time, and sets the break time if they are on a break. BTW, it doesn't make sense that you want the login times and break times to be set depending on each other's condition while being 'independent' of each other.

Using React To Create a Loop That Updates A Number Value

Trying to create an auto-clicker/idle game. So far the entire application works except for this loop. After the loop begins, if I update the counter, different values update in intervals. So my counter will display those different values, going back and forth between them depending on how many times I've tried to mess with the counter while its looping.
I've tried using while loops, if statements, and for loops. And for each of those loops I've tried both setInterval() and setTimeout(). They either lead to the problem above, or the browser crashing.
Here's a video of the issue:
Youtube Link
Here's the relevant code I've got currently:
const Counter = () => {
const [counter, setCounter] = useState(1);
const [minions, setMinions] = useState(0);
let minionCost = minions * 10 + 6;
let autoMinions = () => {
if (minions > 0) {
setTimeout(() => {
setCounter(minions + counter);
}, 1000);
} else {
return null;
}
};
const onClickMinion = () => {
if (counter < minionCost) {
console.log(`you don't have ${minionCost} to spend`);
} else {
setCounter(counter - minionCost);
setMinions(minions + 1);
}
};
autoMinions();
};
If you're computing state based off of a previous state, you should use functional updates.
Try passing setCounter a function that receives the previous state instead of using counter directly (do this with any of your useState hooks that depend on previous state):
setCounter(prevCounter => prevCounter + minions)

Countdown hook loses 1 second every minute

I'm implementing a countdown in my react-native app, but something is not working properly.
It seems that the countdown loses 1 seconds every minute (as you can see in the gif, it jumps between 33 and 31)
this is the code:
import {
differenceInDays,
differenceInHours,
differenceInMinutes,
differenceInSeconds,
isBefore,
parseISO,
} from 'date-fns'
import { useEffect, useState } from 'react'
type CountdownResult = {
days: number
hours: number
minutes: number
seconds: number
}
const calculateInitialDuration = (endDate: string, today: Date): CountdownResult => {
const futureDate = new Date(endDate)
const days = differenceInDays(futureDate, today)
const hours = differenceInHours(futureDate, today) % 24
const minutes = differenceInMinutes(futureDate, today) % 60
const seconds = differenceInSeconds(futureDate, today) % 60
return { days, hours, minutes, seconds }
}
const EXPIREDRESULT: CountdownResult = { days: 0, hours: 0, minutes: 0, seconds: 0 }
// TODO: FIXME: sometimes the countdown jumps directly between 2 seconds
// even if the real time passed is 1 second
// this was happening before the refactor too
const useCountdown = (endDate: string): CountdownResult => {
const today = new Date()
const formattedEndDate = parseISO(endDate)
// doing this because at the beginning countdown seems stuck on the first second
// maybe there is a better solution for this problem
const initialCountdown = calculateInitialDuration(endDate, today)
initialCountdown.seconds++
const [time, setTime] = useState(isBefore(formattedEndDate, today) ? EXPIREDRESULT : initialCountdown)
useEffect(() => {
if (isBefore(formattedEndDate, today)) return
const intervalId = setInterval(() => {
setTime(calculateInitialDuration(endDate, today))
}, 1000)
return (): void => clearInterval(intervalId)
}, [time])
return time
}
export default useCountdown
endDate is a string following the ISO 8601 format.
I'm using date-fns but I also tried the basic javascript implementation, bug is still the same.
Another strange thing is that the countdown, at the beginning, is stuck for one second on the first second (that's the reason why I created the initialCountdown variable), but actually I don't like the solution.
Any tips? Where are the mistakes? Thanks in advance.
At the moment you are assuming that setInterval() triggers the callback every 1,000 milliseconds.
setInterval(() => {
setTime(calculateInitialDuration(endDate, today))
}, 1000)
Unfortunately, with everything else that browser has to do, there's no guarantee that it will.
What you will need to do to gain more accuracy is repeatedly use setTimeout() calculating how long to set the timeout for.
let timeout;
const start = (() => {
// IIFE because func needs to be able to reference itself!
let func = () => {
// Do whatever you need to do here
let now = new Date();
let timeToNextSecond = 1000 - (now.getTime() % 1000);
console.log('Now: ', now, 'TimeToNext: ', timeToNextSecond);
timeout = setTimeout(func, timeToNextSecond);
};
return func;
})();
const stop = () => clearTimeout(timeout);
start();
// wait 10 seconds(ish)
setTimeout(stop, 10000);
If you run this, you will see that subsequent timeouts run shortly after the start of the next second. Assuming that the browser isn't bogged down doing other stuff, it will run every second.
Thoughts: I imagine that setInterval does something like this behind the scenes, just with a fixed timeout causing the drift.

Categories

Resources