Simple data reloading with RxJS

In most scenarios, applications need to fetch data from a remote server. Typically, the client sends requests with certain parameters—like userId for user profiles or cardId for card information. These parameters often come from the route, browser storage, or component attributes. But what happens when you've already fetched the data and simply need to refresh it, without re-supplying those same parameters repeatedly? It seems straightforward, doesn't it?

That depends.

  • When you aim to keep things fully reactive and write declarative code.
  • When you prefer not to introduce extra variables whose only purpose is to hold parameters for reload scenarios.
  • When you intend to build reusable pieces of logic.
  • When minimizing boilerplate is a priority.
  • When the solution should remain easy to understand.

In that case, I would argue it's not that simple.

Across the projects I've worked on, I consistently observed at least two different approaches to this problem. Our goal here is to design a solid data reload pattern using the RxJS library.

If you're curious about the final solution, read on!

Starting point without reload logic

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>;
  }
}
  • Identity – handles passing through the input value unchanged.
  • User – the model type we'll work with.
  • UserMockWebService – a stand-in for a real web service.
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);

UserService – supplies data obtained from the server whenever the userId value changes.

At the bottom of the snippet you'll find the demo part, which produces the following output:

{ id: 2, name: "Liza" }
{ id: 3, name: "Suzi" }

First iteration of reload logic

Time to introduce the reload capability. Let's get acquainted with the scan operator.

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();
    }
}

Here's how it works in practice:

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" }

It functions correctly! But we've only handled user data so far. What if we also need to refresh card details? We'd have to duplicate the same scan-based pattern in multiple places, which isn't ideal. Let's refactor this into something more generic.

Introducing a custom reload operator

To prevent code duplication, we can extract the scan logic into its own operator:

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 we can swap out the scan call for a single reload operator, reducing it to one line:

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();
    }
}

That's an improvement! Still, we have some repetitive setup to manage—pairing merge() with this.reload$ and adding the reload() operator inside the pipe every time we want this behavior. Fortunately, we can simplify further.

Using the combineReload factory function

To cut down on the repetitive code from the previous example, we can leverage a factory function named combineReload(). This helper wraps all the necessary logic for us. Here's its implementation:

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)
  );
}

We can now drop the reload() operator entirely and rely on the combineReload() factory function instead.

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();
  }
}

The result is tidy and polished. What's more, it's reusable and straightforward to integrate!