Error on blur page

Learn how to perform the event reducer pattern with RxJS's scan operator.

Video

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

The problem

In this section, we will:

  • Only show the cardNumber error if the user blurs the card number input. Once the user blurs, we will update the displayed cardNumber error, if there is one, on every future keystroke.
  • Add class="is-error" to the input when it has an error.

How to solve this problem

  • Create a this.userCardNumberBlurred$ Subject that emits when the cardNumber input is blurred.
  • Create a this.showCardError$ that emits true when the cardNumber error should be shown.
  • Create a showOnlyWhenBlurredOnce(error$, blurred$) operator that returns the showCardError$ observable from two source observables.
    showOnlyWhenBlurredOnce should use the event-reducer pattern to promote the error$ and blurred$ into events and reduce those events into the showCardError$ observable.

What you need to know

  • One of the most useful patterns in constructing streams is the event-reducer pattern. On a high-level it involves turning values into events, and using those events to update a stateful object.

    For example, we might have a first and a last stream:

    <script src="https://cdnjs.cloudflare.com/ajax/libs/rxjs/7.4.0/rxjs.umd.min.js"></script>
    <script type="typescript">
      const { of, zip, timer, from, merge } = rxjs;
      const { delay, map, scan } = rxjs.operators;
    
      function sequentially(value, dueTime, period) {
        return zip(
          from(value),
          timer(dueTime, period),
          value => value
        );
      }
    
      const first = sequentially(["Justin", "Ramiya"], 0, 1000);
      const last = sequentially(["Shah", "Meyer"], 500, 1000);
    
      first.subscribe((v) => console.log("first", v));
      last.subscribe((v) => console.log("last", v));
      // first: -Justin---RamiyaX
      // last:  ----Shah__---Meyer_X
    </script>
    

We can promote these to event-like objects with map:

const firstEvents = first.pipe(
  map((first) => {
    return { type: 'first', value: first };
  })
);

const lastEvents = last.pipe(
  map((last) => {
    return { type: 'last', value: last };
  })
);
// firstEvents: -{t:fst,v:Jus}---{t:fst,v:Ram}X
// lastEvents:  ----{t:lst,v:Sha}---{t:lst,v:Myr}X

Next, we can merge these into a single stream:

const merged = merge(firstEvents, lastEvents);
// merged:  -{ type: "first", value: "Justin" }
//          -{ type: "last",  value: "Shah" }
//          -{ type: "first", value: "Ramiya" }
//          -{ type: "last",  value: "Meyer" }X

We can "reduce" (or scan) these events based on a previous state. The following copies the old state and updates it using the event data:

const state = merged.pipe(
  scan(
    (previous, event) => {
      switch (event.type) {
        case 'first':
          return { ...previous, first: event.value };
        case 'last':
          return { ...previous, last: event.value };
        default:
          return previous;
      }
    },
    { first: '', last: '' }
  )
);
// state:  -{ first: "Justin", last: "" }
//         -{ first: "Justin", last: "Shah" }
//         -{ first: "Ramiya", last: "Shah" }
//         -{ first: "Ramiya", last: "Meyer" }X

The following is an even more terse way of doing the same thing:

const state = merged.pipe(
  scan(
    (previous, event) => {
      return { ...previous, [event.type]: event.value };
    },
    { first: '', last: '' }
  )
);

Finally, we can map this state to another value:

const fullName = state.pipe(map((state) => state.first + ' ' + state.last));
// fullName: -Justin
//            -Justin Shah
//            -Ramiya Shah
//            -Ramiya MeyerX

See it all together here:

<script src="https://cdnjs.cloudflare.com/ajax/libs/rxjs/7.4.0/rxjs.umd.min.js"></script>
<script type="typescript">
  const { of, zip, timer, from, merge } = rxjs;
  const { delay, map, scan } = rxjs.operators;

  function sequentially(value, dueTime, period) {
    return zip(
      from(value),
      timer(dueTime, period),
      value => value
    );
  }

  const first = sequentially(["Justin", "Ramiya"], 0, 1000);
  const last = sequentially(["Shah", "Meyer"], 500, 1000);

  const firstEvents = first.pipe(
    map((first) => {
      return { type: "first", value: first };
    })
  );

  const lastEvents = last.pipe(
    map((last) => {
      return { type: "last", value: last };
    })
  );

  const merged = merge(firstEvents,lastEvents);

  const state = merged.pipe(
    scan(
      (previous, event) => {
        switch (event.type) {
          case "first":
            return { ...previous, first: event.value };
          case "last":
            return { ...previous, last: event.value };
          default:
            return previous;
        }
      },
      { first: "", last: "" }
    )
  );

  const fullName = state.pipe(map(state => state.first + " " + state.last));

  fullName.subscribe((fullName) => console.log(fullName));
</script>

NOTE: fullName can be derived more simply from combine. The reducer pattern is used here for illustrative purposes. It is able to support a larger set of stream transformations than combine.

  • For a blur event, we should not save the last publish value so a Subject will work better than a BehaviorSubject.

  • Use a property binding to set a property or attribute on an element.
    To add my-class to the className when testValue is truthy:

    <div [class.my-class]="testValue"></div>
    
  • ngIf is used to conditionally render an element. The following will show the div if expression is truthy:

    <div *ngIf="expression">Show this</div>
    

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, Subject, merge } = rxjs;
  const { map, tap, scan } = 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));
  };

  function showOnlyWhenBlurredOnce(error$, blurred$) {
    const errorEvents$ = error$.pipe(
      map((error) => {
        return { type: error ? "invalid" : "valid" };
      })
    );

    const focusEvents$ = blurred$.pipe(
      map((isBlurred) => {
        return { type: isBlurred ? "blurred" : "focused" };
      })
    );

    const events$ = merge(errorEvents$, focusEvents$);

    const eventsToState = scan(
      (previous, event) => {
        switch (event.type) {
          case "valid":
            return { ...previous, isValid: true, showCardError: false };
          case "invalid":
            return {
              ...previous,
              isValid: false,
              showCardError: previous.hasBeenBlurred,
            };
          case "blurred":
            return {
              ...previous,
              hasBeenBlurred: true,
              showCardError: !previous.isValid,
            };
          default:
            return previous;
        }
      },
      {
        hasBeenBlurred: false,
        showCardError: false,
        isValid: false,
      }
    );

    const state$ = events$.pipe(eventsToState);

    return state$.pipe(map((state) => state.showCardError));
  }

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

        <input
          type="text"
          name="cardNumber"
          placeholder="Card Number"
          (input)="userCardNumber$.next($event.target.value)"
          (blur)="userCardNumberBlurred$.next(true)"
          [class.is-error]="showCardError$ | async"
        />

        <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>();
    userCardNumberBlurred$ = new Subject<boolean>();

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

  // 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>