Reputation: 55
I'm making a stopwatch and when I wanna reset the clock for the second time, it is not changed. On click at the first time, it sets h: 0, m: 0, s: 0. But when click again, it doesn't set h: 0, m: 0, s: 0 and stopwatch goes ahead.
const events$ = merge(
fromEvent(startBtn, 'click').pipe(mapTo({count: true})),
click$.pipe(mapTo({count: false})),
fromEvent(resetBtn, 'click').pipe(mapTo({time: {h: 0, m: 0, s: 0}})) // there is reseting
)
const stopWatch$ = events$.pipe(
startWith({count: false, time: {h: 0, m: 0, s: 0}}),
scan((state, curr) => (Object.assign(Object.assign({}, state), curr)), {}),
switchMap((state) => state.count
? interval(1000)
.pipe(
tap(_ => {
if (state.time.s > 59) {
state.time.s = 0
state.time.m++
}
if (state.time.s > 59) {
state.time.s = 0
state.time.h++
}
const {h, m, s} = state.time
secondsField.innerHTML = s + 1
minuitesField.innerHTML = m
hours.innerHTML = h
state.time.s++
}),
)
: EMPTY)
stopWatch$.subscribe()
Upvotes: 0
Views: 107
Reputation: 8032
You're using mutable state and updating it as a side-effect of events being emitted by observable (That's what tap does).
In general, it's a bad idea to create side effects that indirectly alter the stream they're created in. So creating a log or displaying a value are unlikely to cause issues, but mutating an object and then injecting it back the stream is difficult to maintain/scale.
Create a new object.
// fromEvent(resetBtn, 'click').pipe(mapTo({time: {h: 0, m: 0, s: 0}}))
fromEvent(resetBtn, 'click').pipe(map(_ => ({time: {h: 0, m: 0, s: 0}})))
That should work, though it's admittedly a band-aid solution.
Here's a stopwatch I made a while ago. Here's how it works. You create a stopwatch by giving it a control$
observable (I use a Subject called controller
in this example).
When control$
emits "START"
, the stopWatch starts, when it emits "STOP"
, the stopwatch stops, and when it emits "RESET"
the stopwatch sets the counter back to zero. When control$
errors, completes, or emits "END"
, the stopwatch errors or completes.
function createStopwatch(control$: Observable<string>, interval = 1000): Observable<number>{
return defer(() => {
let toggle: boolean = false;
let count: number = 0;
const ticker = () => {
return timer(0, interval).pipe(
map(x => count++)
)
}
return control$.pipe(
catchError(_ => of("END")),
s => concat(s, of("END")),
filter(control =>
control === "START" ||
control === "STOP" ||
control === "RESET" ||
control === "END"
),
switchMap(control => {
if(control === "START" && !toggle){
toggle = true;
return ticker();
}else if(control === "STOP" && toggle){
toggle = false;
return EMPTY;
}else if(control === "RESET"){
count = 0;
if(toggle){
return ticker();
}
}
return EMPTY;
})
);
});
}
// Adapted to your code :)
const controller = new Subject<string>();
const seconds$ = createStopwatch(controller);
fromEvent(startBtn, 'click').pipe(mapTo("START")).subscribe(controller);
fromEvent(resetBtn, 'click').pipe(mapTo("RESET")).subscribe(controller);
seconds$.subscribe(seconds => {
secondsField.innerHTML = seconds % 60;
minuitesField.innerHTML = Math.floor(seconds / 60) % 60;
hours.innerHTML = Math.floor(seconds / 3600);
});
As a bonus, you can probably see how you might make a button that Stops
this timer without resetting it.
Here's an even more idiomatically reactive way to do this. It makes a control$
for the stopwatch by merging DOM events directly (No Subject in the middle).
This does take away your ability to write something like controller.next("RESET");
to inject your own value into the stream at will. OR controller.complete();
when your app is done with the stopwatch (Though you might do that automatically through some other event instead).
...
// Adapted to your code :)
createStopwatch(merge(
fromEvent(startBtn, 'click').pipe(mapTo("START")),
fromEvent(resetBtn, 'click').pipe(mapTo("RESET"))
)).subscribe(seconds => {
secondsField.innerHTML = seconds % 60;
minuitesField.innerHTML = Math.floor(seconds / 60) % 60;
hours.innerHTML = Math.floor(seconds / 3600);
});
Upvotes: 1