Logo

dev-resources.site

for different kinds of informations.

Creating Custom rxResource API With Observables

Published at
11/14/2024
Categories
angular
rxjs
webdev
tutorial
Author
krivanek06
Categories
4 categories in total
angular
open
rxjs
open
webdev
open
tutorial
open
Author
10 person written this
krivanek06
open
Creating Custom rxResource API With Observables

At the time of writing this article, Angular is approaching the version 19 release and it brings a new API called resource. A great in-depth article is to read Enea Jahollari - Everything you need to know about the resource API.

While the resource API is available from version 19, Angular npm downloads show that many projects are still stuck on version 12-15.
Since these older versions heavily use Observables, I want to attempt creating a similar wrapper as rxResource, only it would work with Observables instead of signals.

Application Overview

Below you can see a simple app on which we can demonstrate the main functionalities of rxResource API. Our goals is to fulfill the following requirements:

  • As the user increments the counter, load more items
  • Display the loading state while the data is being loaded
  • Display the error state if the HTTP call fails
  • Create a refresh button that will reload the data
  • Enable editing displayed items - remove them from the UI by clicking

Image description

Solving It With The rxResource

With the new resource API, this example can be solved similarly as shown below

@Component({
  selector: 'app-resource-normal-example',
  standalone: true,
  imports: [FormsModule],
  template: `
    <div class="grid gap-y-2">
      <h1>Resource Normal Example</h1>

      <button (click)="todosResource.reload()">
        refresh
      </button>

      <input type="number" [(ngModel)]="limitControl"/>

      <!-- loading state -->
      @if (todosResource.isLoading()) {
        <div class="g-loading">Loading...</div>
      }

      <!-- error state -->
      @else if (todosResource.error()) {
        <div class="g-error">
          {{ todosResource.error() }}
        </div>
      }

      <!-- display data -->
      @else if (todosResource.hasValue()) {
        @for (item of todosResource.value() ?? []; track $index) {
          <div class="g-item" (click)="onRemove(item)">
            {{ item.id }} -{{ item.title }}
          </div>
        }
      }
    </div>
  `,
})
export class ResourceNormalExample {
  private http = inject(HttpClient);
  limitControl = signal<number>(5);

  todosResource = rxResource({
    request: this.limitControl,
    loader: ({ request: limit }) => {
      return this.http.get<Todo[]>(
        `https://jsonplaceholder.typicode.com/todos?_limit=${limit}`
      ).pipe(
        map((res) => {
          if (limit === 8) {
            throw new Error('Error happened on the server');
          }
          return res;
        }),
        delay(1000),
      );
    },
  });

  onRemove(todo: Todo) {
    this.todosResource.update(
      (d) => d?.filter((item) => item.id !== todo.id)
    );
  }
}
Enter fullscreen mode Exit fullscreen mode

To briefly summarize, whenever the limitControl signal changes, it will rerun the loader part of the rxResource. The loader takes the limit value as an argument and creates an HTTP request. We use delay to simulate slow network (to display the loading state) and if you select number 8, an error will be thrown to demonstrate the error state.

On every click on a single displayed Todo item, the item will be removed from the todosResource, and, lastly, there is a refresh button to reload todos from the API.

Creating a Custom rxResource

This part will be divided into two section. First, we create a basic rxResourceCustomBasic function that will return the state of the request (loading, loaded, etc.) with the loaded data, and then we will create a more complex version of this to also allow refreshing, updating and setting a new value manually.

Defining The Correct Types

We need to define what types we are working with, such as the data, the error and the loading state of the request, so we may go with something like the following:

// this is the primary type we will be working with
type RxResourceCustomResult<T> = {
  /**
   * states:
   * - `loading` - the resource is loading
   * - `loaded` - the resource has been loaded
   * - `error` - an error occurred while loading the resource
   * - `local` - the resource has been set/modified locally
   */
  state: 'loading' | 'loaded' | 'error' | 'local';
  isLoading: boolean;
  data: T | null;
  error?: unknown;
};

// in theory you could also go with the one below,
// but its usage were a bit different from what Angular's rxResource has
type RxResourceCustomResult<T> = {
  state: 'loading'
} | {
  state: 'local'
} | {
  state: 'loaded',
  data: T | null;
} | {
  state: 'error',
  error: unknown;
}
Enter fullscreen mode Exit fullscreen mode

Creating A Function Skeleton

To create the skeleton for the custom function that will look the same as Angular’s rxResource, we may go with something like:

export const rxResourceCustomBasic = (data: {
  request: any[];
  loader: (values: any) => Observable<any>;
}): Observable<RxResourceCustomResult<any>> => {
    // todo ....
  return of({} as RxResourceCustomResult<any>);
}

Enter fullscreen mode Exit fullscreen mode

The request accepts an array of something, ideally observables. It will
differ from rxResource in syntax that we are expecting an array,
however rxResource uses the following syntax request: () => ({limit: this.limitControl}).

The loader is a closure function provided by the user, that can
access (the observable) values from the request parameter and it returns an observable (a HTTP request).

Since initially everything is a type of any, TS doesn’t suggest that the value inside the loader array should a be number. Let’s fix it:

// extract the value from an observable
type ObservableValue<T> = T extends Observable<infer U> ? U : never;

export const rxResourceCustomBasic = <
    T, 
    TLoader extends Observable<unknown>[]
>(data: {
  request: [...TLoader];
  loader: (values: {
    [K in keyof TLoader]: ObservableValue<TLoader[K]>;
  }) => Observable<T>;
}): Observable<RxResourceCustomResult<T>> => {

  return of({ } as RxResourceCustomResult<any>);
}
Enter fullscreen mode Exit fullscreen mode

A bit of generics are used, but what’s happening is that the first T in rxResourceCustomBasic represents the shape of the data from an API and TLoader extends Observable<unknown>[] represents an array of observable dependencies that we will be listening to.
I will not explain the whole generic structure, you can visit the Github example
if you want to investigate what happens by tweaking the types.

Now when you use the rxResourceCustomBasic you will see the correct types. For example the loader has exactly 3 parameters and each has its own correct type.

Custom rxResource wrapper with correct types

Basic Custom rxResource

Types are correctly solved, now let’s create the basic version of our custom rxResource, that has the state, data and error properties.

export const rxResourceCustomBasic = <
  T, 
  TLoader extends Observable<unknown>[]
>(data: {
  request: [...TLoader];
  loader: (values: {
    [K in keyof TLoader]: ObservableValue<TLoader[K]>;
  }) => Observable<T>;
}): Observable<RxResourceCustomResult<T>> => {
  // listen to all the requests observables
  return combineLatest(data.request).pipe(
    switchMap((values) =>
      // execute the loader function provided by the user
      data
        .loader(
          values as {
            [K in keyof TLoader]: ObservableValue<TLoader[K]>;
          },
        )
        .pipe(
          switchMap((result) =>
            of({
              state: 'loaded' as const,
              data: result,
            }),
          ),
          // setup loading state
          startWith({
            state: 'loading' as const,
            data: null,
          }),
          // handle error state
          catchError((error) =>
            of({
              state: 'error' as const,
              error,
              data: null,
            }),
          ),

          // map the result to the expected type
          map(
            (result) =>
              ({
                ...result,
                isLoading: result.state === 'loading',
              }) satisfies RxResourceCustomResult<T>,
          ),
        ),
    ),
    // share the observable
    shareReplay(1),
  );
};

Enter fullscreen mode Exit fullscreen mode
  • We use combineLatest to listen on any new emission from the array of observables in data.request and we want to rerun the logic if any of those observables emit a new value.
  • I use casting value as .... because TS suggest that value is a type of unknown[]. Not sure how to fix it, this was the easiest way.
  • The data.loader(values) is used to provide the arguments from the request part into the loader part function provided by the user.
  • Using the rxjs startWith, the initial state is loading and every time any observable emits, the state will always start with the loading state.
  • Using the catchError to handle error state and add it to the source that can throw an error. If you are confused why catchError is not the last operator, consider checking out Angular Rxjs - CatchError Position Matter!
  • The shareReplay() operator is used to broadcast a newly emitted value to each subscriber, making it a hot observable

Using the basic custom rxResource

Advanced Custom rxResouce

Although, the basic example works fine, there are some small issues with it. The main one is that the rxResourceCustomBasic returns an observable and we always have to subscribe on it, even if we only want the most current value.

Second, we are missing some methods, which the Angular’s rxResource provides, such as reload(), update() and set(). Let’s create a more advanced version that will have all the above mentioned features.


export type RxResourceCustom<T> = {
  /**
   * Trigger a reload of the resource
   */
  reload: () => void;
  /**
   * @returns the current result of the resource
   */
  value: () => T | null;
  /**
   * @param updateFn - function to update the current data
   */
  update: (updateFn: (current: T) => T) => void;
  /**
   * @param data - set the data of the resource
   */
  set: (data: T) => void;
  /**
   * Observable of the resource state
   */
  result$: Observable<RxResourceCustomResult<T>>;
};

export const rxResourceCustom = <
  T, 
  TLoader extends Observable<unknown>[]
>(data: {
  request: [...TLoader];
  loader: (values: {
    [K in keyof TLoader]: ObservableValue<TLoader[K]>;
  }) => Observable<T>;
}): RxResourceCustom<T> => {
  // Subject to trigger reloads
  const reloadTrigger$ = new Subject<void>();

  // hold the latest result of type `T | null`
  const resultState$ = new BehaviorSubject<{
    state: RxResourceCustomResult<T>['state'];
    data: T | null;
  }>({
    state: 'loading' as const,
    data: null,
  });

  const result$ = reloadTrigger$.pipe(
    startWith(null),
    // listen to all the requests observables
    combineLatestWith(...data.request),
    // prevent request cancellation
    exhaustMap(([_, ...values]) =>
      // execute the loader function provided by the user
      data
        .loader(
          values as {
            [K in keyof TLoader]: ObservableValue<TLoader[K]>;
          },
        )
        .pipe(
          switchMap((result) =>
            of({
              state: 'loaded' as const,
              data: result,
            }),
          ),

          // setup loading state
          startWith({
            state: 'loading' as const,
            data: null,
          }),

          // handle error state
          catchError((error) =>
            of({
              state: 'error' as const,
              error,
              data: null,
            }),
          ),
        ),
    ),
  );

  // subscribe to the result and update the state
  result$.pipe(takeUntilDestroyed()).subscribe(
    (state) => resultState$.next(state)
  );

  return {
    result$: resultState$.asObservable().pipe(
      map((state) => ({
        ...state,
        isLoading: state.state === 'loading',
      })),
    ),
    reload: () => reloadTrigger$.next(),
    value: () => resultState$.value.data,
    update: (updateFn: (current: T) => T) => {
      const current = resultState$.value;
      if (current?.data) {
        resultState$.next({
          state: 'local',
          data: updateFn(current.data),
        });
      }
    },
    set: (data) => {
      resultState$.next({
        state: 'local',
        data: data,
      });
    },
  };
};
Enter fullscreen mode Exit fullscreen mode

When user executes the reload() function, it will next the reloadTrigger$ and re-execute
the provided logic inside the request parameter.

Using exhaustMap allows us to ignore subsequent requests when the user
triggers the reload() function multiple times until the first one is not finished.

With the combineLatestWith operator we are constantly listening on the observable dependencies (from request) and if any of them emit, the logic (inside the loader) is re-executed.

TheresultState$ behaviourSubject is used to keep the current state of the loader section, so that we can return the current value and modify the cached result.

You may see that in this example we replaced the shareReplay(1) with takeUntilDestroyed(),
meaning, whenever you leave the execution context (component is destroyed), the subscription
is cancelled and you avoid memory leaks.

Note: Keep in mind that takeUntilDestroyed operator is available from
Angular version 16, so you need to be on at latest
version 16 when using the above advanced example.

Using The Custom rxResource

Finally we can now use the custom rxResourceCustom that we’ve created as follows:

@Component({
  selector: 'app-resource-custom-example',
  standalone: true,
  imports: [ReactiveFormsModule, AsyncPipe],
  template: `
    <div class="grid gap-y-2">
      <h1>Resource Custom Example</h1>

      <button (click)="todosResource.reload()">refresh</button>

      <input type="number" [formControl]="limitControl" />

      @if (todosResource.result$ | async; as data) {
        <!-- loading state -->
        @if (data.isLoading) {
          <div class="g-loading">Loading...</div>
        }

        <!-- error state -->
        @else if (data.error) {
          <div class="g-error">
            {{ data.error }}
          </div>
        }

        <!-- display data -->
        @for (item of data.data; track $index) {
          <div class="g-item" (click)="onRemove(item)">
              {{ item.id }} - {{ item.title }}
          </div>
        }
      }
    </div>
  `,
  styles: [],
  changeDetection: ChangeDetectionStrategy.OnPush,
})
export class ResourceCustomExampleComponent {
  private http = inject(HttpClient);
  limitControl = new FormControl<number>(5, { nonNullable: true });

  private limitValue$ = this.limitControl.valueChanges.pipe(
        startWith(this.limitControl.value)
    );

  todosResource = rxResourceCustom({
    request: [this.limitValue$],
    loader: ([limit]) => {
      return this.http.get<Todo[]>(
          `https://jsonplaceholder.typicode.com/todos?_limit=${limit}`
         ).pipe(
        map((res) => {
          if (limit === 8) {
            throw new Error('Error happened on the server');
          }
          return res;
        }),
        delay(1000),
      );
    },
  });

  onRemove(todo: Todo) {
    this.todosResource.update(
        (d) => d?.filter((item) => item.id !== todo.id)
      );
  }

    constructor(){
    // log the current value
    console.log(this.todosResource.value());
  }
 }
Enter fullscreen mode Exit fullscreen mode

The Final Result

The final result of the side by side comparison of the Angular’s rxResource on the left, and our custom observable rxResource on the right

Final result

Hope you liked the article and found it helpful. If you want to play around with it,
you can check the Github repo.
Also feel free to connect with me on LinkedIn or visit my dev.to account for more content.

Image description

rxjs Article's
30 articles in total
Favicon
rxjs
Favicon
Angular Signals and Their Benefits
Favicon
Migrando subscribe Callbacks para subscribe arguments no RxJS
Favicon
New in Angular: Bridging RxJS and Signals with toSignal!
Favicon
RxSignals: The Most Powerful Synergy in the History of Angular 🚀
Favicon
Virtually Infinite Scrolling with Angular
Favicon
Building a Collapsible UI Component in Angular: From Concept to Implementation 🚀
Favicon
Disabling button on api calls in Angular
Favicon
Angular Dependency Injection — Inject service inside custom Rxjs operators
Favicon
Unsubscribe Observable! why is it so important?
Favicon
Harnessing the Power of RxJS with React for Asynchronous Operations
Favicon
DOM Observables, Rx7, Rx8 and the observable that doesn't exist yet
Favicon
Advanced RxJs Operators You Know But Not Well Enough pt 2.
Favicon
Observables in Node.js: Transforming Asynchronous Chaos into Elegant Code
Favicon
Reusable, Extensible and Testable State Logic with Reactive Programming.
Favicon
Understanding RxJS and Observables in Angular: A Beginner-Friendly Guide
Favicon
Are Angular Resolvers on Life Support ?
Favicon
Creating Custom rxResource API With Observables
Favicon
Forestry: The Best State System for React and (not React)
Favicon
📢 Announcing the New Reactive State Management Libraries! 🎉
Favicon
Reactables: Reactive State Management for any UI Framework.
Favicon
Hot vs Cold Observables in Angular: Understanding the Difference
Favicon
RxJS Simplified with Reactables
Favicon
How I structure my Angular components with Signals
Favicon
Angular LAB: let's create a Visibility Directive
Favicon
Difference between mergeMap vs switchMap vs concatMap vs exhaustMap
Favicon
How Signals Improve Your App Performance
Favicon
The Article I Took So Long to Write: Concepts about Reactive Programming and RxJS
Favicon
Tame Your Asynchronous World with RxJS
Favicon
Custom RxJS Operators to Improve your Angular Apps

Featured ones: