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 thecardNumber
input is blurred. - Create a
this.showCardError$
that emits true when thecardNumber
error should be shown. - Create a
showOnlyWhenBlurredOnce(error$, blurred$)
operator that returns theshowCardError$
observable from two source observables.
showOnlyWhenBlurredOnce
should use the event-reducer pattern to promote theerror$
andblurred$
into events and reduce those events into theshowCardError$
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 alast
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 fromcombine
. The reducer pattern is used here for illustrative purposes. It is able to support a larger set of stream transformations thancombine
.
For a blur event, we should not save the last publish value so a
Subject
will work better than aBehaviorSubject
.Use a property binding to set a property or attribute on an element.
To addmy-class
to theclassName
whentestValue
is truthy:<div [class.my-class]="testValue"></div>
ngIf is used to conditionally render an element. The following will show the
div
ifexpression
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>