Table of contents
Most of the time, we have to load data from the server. To perform the action client usually sends requests along with predefined data. Such data usually takes from the route, browser storage, or from attributes in the case when it's a component. To load the user details we need to have userId, to load card details we need to have cardId, and so on. But what if you already load the data and you just need to reload without passing the same predefined data again and again. Sounds like a trivial task, right?
It depends.
- If we want to stay reactive and write declarative code.
- If we don't want to create new variables which responsibility will be to keep predefined data only for data reloading.
- If we want to deal with reusable code.
- If we want to avoid boilerplate as much as possible.
- If the code should stay simple.
Then I would stick with no.
I've noticed that every project that I was working on had two and more distinct solutions. Our mission will be to develop data reload pattern using the RxJS library and do it well.
If you're interested in the approach we came up with, stay tuned!
Do you want to follow along?
The final version can be found on GitHub. Remove everything from the src/index.ts and you're ready to go, or just create a new TypeScript project from the scratch.
Initial code without any data reload implementation
import { Observable, of, ReplaySubject } from 'rxjs';
import { switchMap } from 'rxjs/operators';
function Identity<T>(value: T): T {
return value;
}
interface User {
id: number;
name: string;
}
class UserMockWebService {
readonly users: User[] = [
{ id: 1, name: 'John' },
{ id: 2, name: 'Liza' },
{ id: 3, name: 'Suzy' }
];
getUserById(id: number): Observable<User> {
const user = this.users.find((user: User) => {
return user.id === id;
});
return of(user) as Observable<User>;
}
}
class UserService {
private idRplSubj = new ReplaySubject<number>(1);
userObs$: Observable<User> = this.idRplSubj
.pipe(
switchMap((userId: number) => {
return this.userWebService.getUserById(userId);
})
);
constructor(private userWebService: UserMockWebService) {}
setId(id: number): void {
this.idRplSubj.next(id);
}
}
// Demo
const userWebService = new UserMockWebService();
const userService = new UserService(userWebService);
userService.userObs$.subscribe(console.log);
userService.setId(2);
userService.setId(3);
At the end of the code, you can see the demo section, which will output into the following:
{ id: 2, name: "Liza" }
{ id: 3, name: "Suzi" }
Initial data reload — the draft
Now we’re ready to implement data reload functionality. Let’s meet the
class UserService {
private reloadSubj = new Subject<void>();
private idRplSubj = new ReplaySubject<number>(1);
userObs$: Observable<User> =
merge(
this.idRplSubj,
this.reloadSubj
)
.pipe(
scan((oldValue, currentValue) => {
if(!oldValue && !currentValue)
throw new Error(`Reload can't run before initial load`);
return currentValue || oldValue;
}),
switchMap((userId: number) => {
return this.userWebService.getUserById(userId);
})
);
constructor(private userWebService: UserMockWebService) {}
setId(id: number): void {
this.idRplSubj.next(id);
}
reload(): void {
this.reloadSubj.next();
}
}
Let’s take a look at it in action:
const userWebService = new UserMockWebService();
const userService = new UserService(userWebService);
userService.userObs$.subscribe(console.log);
userService.setId(2);
userService.setId(3);
userService.reload();
userService.setId(1);
{ id: 2, name: "Liza" }
{ id: 3, name: "Suzi" }
{ id: 3, name: "Suzi" }
{ id: 1, name: "John" }
Everything works! However, here we implemented it for loading users, but we need to load not only the users but the card details as well. In this case, we would need to include the same
Adding custom reload operator
We need to extract the
function reload(selector: Function = Identity) {
return scan((oldValue, currentValue) => {
if(!oldValue && !currentValue)
throw new Error(`Reload can't run before initial load`);
return selector(currentValue || oldValue);
});
}
Now, instead of the
class UserService {
private reloadSubj = new Subject<void>();
private idRplSubj = new ReplaySubject<number>(1);
userObs$: Observable<User> =
merge(
this.idRplSubj,
this.reloadSubj
)
.pipe(
reload(),
switchMap((userId: number) => {
return this.userWebService.getUserById(userId);
})
);
constructor(private userWebService: UserMockWebService) {}
setId(id: number): void {
this.idRplSubj.next(id);
}
reload(): void {
this.reloadSubj.next();
}
}
It’s definitely looking better! However, there is still a code boilerplate that we need to remember. We need to have
Adding the combineReload factory function
In order to avoid boilerplate code from the previous example, we are going to use the factory function called
function combineReload<T>(
value$: Observable<T>,
reload$: Observable<void>,
selector: Function = Identity
): Observable<T> {
return merge(value$, reload$).pipe(
reload(selector),
map((value: any) => value as T)
);
}
Now we can remove the
class UserService {
private reloadSubj = new Subject<void>();
private idRplSubj = new ReplaySubject<number>(1);
userObs$: Observable<User> =
combineReload(
this.idRplSubj,
this.reloadSubj
)
.pipe(
switchMap((userId: number) => {
return this.userWebService.getUserById(userId);
})
);
constructor(private userWebService: UserMockWebService) {}
setId(id: number): void {
this.idRplSubj.next(id);
}
reload(): void {
this.reloadSubj.next();
}
}
Now it looks clean and neat. Moreover, it’s reusable and simple to use!