Debugging page

Learn how to debug RxJS with the tap operator.

Video

Who has time to read? This video covers the content on this page. Watch fullscreen.

The problem

In this section, we will:

  • Learn how to debug RxJS observables.
  • Log the value of the this.cardNumber$ observable everytime it changes.

How to solve this problem

  • Create a log helper that console.logs emitted values without causing side-effects.
  • Use the log helper to log values emitted by this.cardNumber$.

What you need to know

RxJS can be tricky to debug. It seems like you should simply be able to subscribe to an observable and output its values.

The problem with this is that:

  1. Subscribing to an observable changes the state of the observable.
  2. Observables (in contrast to Subjects) run their initialization code every time there is a new subscriber.

Many times you want to subscribe to an intermediate observable to see its value.

The following example creates:

  1. randomNumbers to emit random numbers.
  2. floats0to100 to emit the random numbers multiplied by 100.
  3. ints0to100 to emit the multiplied numbers rounded to the nearest integer.

If you subscribe to floats0to100 to see its values, you will notice that the float values do not match the int values!!

<script src="https://cdnjs.cloudflare.com/ajax/libs/rxjs/7.4.0/rxjs.umd.min.js"></script>
<script type="typescript">
  const { Observable } = rxjs;
  const { map } = rxjs.operators;

  const randomNumbers = new Observable((observer) => {
    observer.next(Math.random());
    observer.next(Math.random());
    observer.next(Math.random());
  });

  // Operators
  const toTimes100 = map((value) => value * 100);
  const toRound = map(Math.round);

  const floats0to100 = randomNumbers.pipe(toTimes100);

  //floats0to100.subscribe((value) => {
  //  console.log("float", value);
  //});

  const ints0to100 = floats0to100.pipe(toRound);

  ints0to100.subscribe((value) => {
      console.log("int", value);
  });
</script>

The tap operator allows you to perform a side-effect (such as logging) on every emission on a source observable.

The following uses tap to log floats0to100 values so they match:

<script src="https://cdnjs.cloudflare.com/ajax/libs/rxjs/7.4.0/rxjs.umd.min.js"></script>
<script type="typescript">
  const { Observable } = rxjs;
  const { map, tap } = rxjs.operators;

  const randomNumbers = new Observable((observer) => {
    observer.next(Math.random());
    observer.next(Math.random());
    observer.next(Math.random());
  });

  // Operators
  const toTimes100 = map((value) => value * 100);
  const toRound = map(Math.round);

  const logFloats = tap((value) => console.log("float", value));

  const floats0to100 = randomNumbers.pipe(toTimes100).pipe(logFloats);

  const ints0to100 = floats0to100.pipe(toRound);

  ints0to100.subscribe((value) => {
    console.log("int", value);
  });
</script>

We can generalize this pattern with a log operator like:

const log = (name) => {
  return tap((value) => console.log(name, value));
};

log can be used as follows:

const number = source.pipe(mapToNumber).pipe(log('number'));

NOTE 1: Notice that to log number, we call .pipe(log(...)) on what would be the number observable.

NOTE 2: The solution will log cardNumber twice. That’s expected because there are two subscriptions on cardNumber:

  • one directly from cardNumber$ in the template - {{ cardNumber$ | async }}
  • the other from cardError$ in the template - {{ cardError$ | async }} - cardError derives from cardNumber.

The solution

Click to see the solution

<script src="https://cdnjs.cloudflare.com/ajax/libs/rxjs/7.4.0/rxjs.umd.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/core-js/3.19.0/core.js"></script>
<script src="https://unpkg.com/@angular/core@12.2.16/bundles/core.umd.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/zone.js/0.11.4/zone.min.js"></script>
<script src="https://unpkg.com/@angular/common@12.2.16/bundles/common.umd.js"></script>
<script src="https://unpkg.com/@angular/compiler@12.2.16/bundles/compiler.umd.js"></script>
<script src="https://unpkg.com/@angular/platform-browser@12.2.16/bundles/platform-browser.umd.js"></script>
<script src="https://unpkg.com/@angular/platform-browser-dynamic@12.2.16/bundles/platform-browser-dynamic.umd.js"></script>
<my-app></my-app>
<script type="typescript">
  // app.js
  const { Component, VERSION } = ng.core;
  const { BehaviorSubject } = rxjs;
  const { map, tap } = rxjs.operators;

  const cleanCardNumber = map((card) => {
    if (card) {
      return card.replace(/[\s-]/g, "");
    }
  });

  const validateCard = map((card) => {
    if (!card) {
      return "There is no card";
    }
    if (card.length !== 16) {
      return "There should be 16 characters in a card";
    }
  });

  const log = (name) => {
    return tap((value) => console.log(name, value));
  };

  @Component({
    selector: 'my-app',
    template: `
      <form>
        <div class="message">{{ cardError$ | async }}</div>

        <input
          type="text"
          name="cardNumber"
          placeholder="Card Number"
          (input)="userCardNumber$.next($event.target.value)"
        />

        <input type="text" name="expiry" placeholder="MM-YY" />

        <input type="text" name="cvc" placeholder="CVC" />

        <button>
          PAY
        </button>
      </form>
      UserCardNumber: {{ userCardNumber$ | async }} <br />
      CardNumber: {{ cardNumber$ | async }} <br />
    `
  })
  class AppComponent {
    userCardNumber$ = new BehaviorSubject<string>();

    constructor() {
      this.cardNumber$ = this.userCardNumber$
        .pipe(cleanCardNumber)
        .pipe(log("cardNumber"));
      this.cardError$ = this.cardNumber$.pipe(validateCard);
    }
  }

  // main.js
  const { BrowserModule } = ng.platformBrowser;
  const { NgModule } = ng.core;
  const { CommonModule } = ng.common;

  @NgModule({
    imports: [
      BrowserModule,
      CommonModule,
    ],
    declarations: [AppComponent],
    bootstrap: [AppComponent],
    providers: []
  })
  class AppModule {}

  const { platformBrowserDynamic } = ng.platformBrowserDynamic;

  platformBrowserDynamic()
    .bootstrapModule(AppModule)
    .catch(err => console.error(err));
</script>
<style>
  @import url('https://fonts.googleapis.com/css?family=Raleway:400,500');
  body {
    background-color: rgba(8, 211, 67, 0.3);
    padding: 2%;
    font-family: 'Raleway', sans-serif;
    font-size: 1em;
  }
  input {
    display: block;
    width: 100%;
    box-sizing: border-box;
    font-size: 1em;
    font-family: 'Raleway', sans-serif;
    font-weight: 500;
    padding: 12px;
    border: 1px solid #ccc;
    outline-color: white;
    transition: background-color 0.5s ease;
    transition: outline-color 0.5s ease;
  }
  input[name='cardNumber'] {
    border-bottom: 0;
  }
  input[name='expiry'],
  input[name='cvc'] {
    width: 50%;
  }
  input[name='expiry'] {
    float: left;
    border-right: 0;
  }
  input::placeholder {
    color: #999;
    font-weight: 400;
  }
  input:focus {
    background-color: rgba(130, 245, 249, 0.1);
    outline-color: #82f5f9;
  }
  input.is-error {
    background-color: rgba(250, 55, 55, 0.1);
  }
  input.is-error:focus {
    outline-color: #ffbdbd;
  }
  button {
    font-size: 1em;
    font-family: 'Raleway', sans-serif;
    background-color: #08d343;
    border: 0;
    box-shadow: 0px 1px 3px 1px rgba(51, 51, 51, 0.16);
    color: white;
    font-weight: 500;
    letter-spacing: 1px;
    margin-top: 30px;
    padding: 12px;
    text-transform: uppercase;
    width: 100%;
  }
  button:disabled {
    opacity: 0.4;
    background-color: #999999;
  }
  form {
    background-color: white;
    box-shadow: 0px 17px 22px 1px rgba(51, 51, 51, 0.16);
    padding: 40px;
    margin: 0 auto;
    max-width: 500px;
  }
  .message {
    margin-bottom: 20px;
    color: #fa3737;
  }
</style>