random name
random name

Reputation: 55

Why mapTo changes only one time?

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

Answers (1)

Mrk Sef
Mrk Sef

Reputation: 8032

The Problem

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.

A sort-of-fix:

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.

A Pre-fab 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.

Without a Subject

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

Related Questions