UNPKG

angular-fetcher

Version:

Signal-based state management for remote API data in Angular with support for reactive loading, error tracking, optimistic updates, and auto refetching.

195 lines (194 loc) 6.27 kB
import { inject, DestroyRef, signal, computed } from "@angular/core"; import { Observable, Subject, defer, throwError } from "rxjs"; import { catchError, finalize, takeUntil, take } from "rxjs/operators"; /** * Manages API data with reactive signals in Angular applications. * * @public * @typeParam T Resource data type. * @typeParam E Error type (default: unknown). * * @param loader A method that returns `Observable<T>` (e.g., from a service method). * @param options Optional configuration, including `destroy$` for cleanup. * * @returns Resource controller with reactive state and methods: * - `state`: Signals for data, loading, and error. * - `fetch()`: Fetches data. * - `update()`: Updates local data. * - `withMutation()`: Executes mutations with optional optimistic updates. * - `invalidate()`: Clears and refetches data. * - `abort()`: Cancels ongoing requests. * * @example * ```ts * // user.service.ts * export class UserService { * getUser() { return this.http.get('/api/user'); } * updateUser(data) { return this.http.put('/api/user', data); } * userResource = withResource(() => this.getUser()); * } * * // user.component.ts * userResource.fetch(); * userResource.withMutation(updateUser({ name: 'foo' }), { * key: 'update-user', * optimisticUpdate: (prev) => ({ ...prev, name: 'foo' }) * }); * ``` */ export function withResource(loader, options = {}) { const emptyValue = options.emptyValue ?? {}; const _data = signal(emptyValue); const _fetchLoading = signal(false); const _mutationLoading = signal(false); const _mutationLoadingKey = signal({}); const _error = signal(null); const abort$ = new Subject(); const _isEmpty = computed(() => { const data = _data(); if (data === null || data === undefined) { return true; } if (typeof data === "string") { return data.length === 0; } if (Array.isArray(data)) { return data.length === 0; } if (typeof data === "object" && data !== null) { return Object.keys(data).length === 0; } return false; }); const destroyRef = inject(DestroyRef, { optional: true }); const fallbackDestroy$ = destroyRef ? new Observable((subscriber) => { destroyRef.onDestroy(() => subscriber.next()); }) : null; const destroy$ = options.destroy$ ?? fallbackDestroy$; if (destroy$) { destroy$.pipe(take(1)).subscribe(() => { abort$.next(); abort$.complete(); }); } /** * Reactive state of the resource. * @public */ const state = { /** Current resource data. */ data: _data, /** Fetch operation status. */ fetchLoading: _fetchLoading, /** Mutation operation status. */ mutationLoading: _mutationLoading, /** Individual mutation loading states by key. */ mutationLoadingKey: _mutationLoadingKey, /** Latest error, if any. */ error: _error, /** Whether resource data is empty. */ isEmpty: _isEmpty, }; let currentRequestId = 0; /** * Fetches resource data, canceling ongoing requests. * * @public * @param handlers Optional success and error callbacks. */ const fetch = (handlers) => { abort$.next(); _fetchLoading.set(true); _error.set(null); const requestId = ++currentRequestId; defer(loader) .pipe(takeUntil(abort$), destroy$ ? takeUntil(destroy$) : (source$) => source$, catchError((err) => { _error.set(err); handlers?.error?.(err); return throwError(() => err); }), finalize(() => _fetchLoading.set(false))) .subscribe({ next: (value) => { if (requestId === currentRequestId) { _data.set(value); handlers?.next?.(value); } }, }); }; /** * Updates resource data with provided function. * * @public * @param updater Function to transform current data. */ const update = (updater) => { _data.update(updater); }; /** * Executes a mutation with optional optimistic updates. * * @public * @param request$ Observable for the mutation request. * @param handlers Mutation configuration and callbacks. * @typeParam R Mutation response type. */ const withMutation = (request$, handlers) => { const key = handlers.key || `mutation-${Date.now()}`; _mutationLoadingKey.set({ ..._mutationLoadingKey(), [key]: true }); _mutationLoading.set(true); _error.set(null); const previousState = _data(); if (handlers.optimisticUpdate) { _data.update((prev) => handlers.optimisticUpdate(prev, {})); } request$ .pipe(takeUntil(abort$), destroy$ ? takeUntil(destroy$) : (source$) => source$, finalize(() => { _mutationLoadingKey.set({ ..._mutationLoadingKey(), [key]: false }); _mutationLoading.set(Object.values(_mutationLoadingKey()).some((v) => v)); })) .subscribe({ next: (res) => { if (handlers.invalidate) { invalidate(); } handlers.next?.(res); }, error: (err) => { if (handlers.optimisticUpdate) { _data.set(previousState); } _error.set(err); handlers.error?.(err); }, }); }; /** * Invalidates resource data and refetches. * @public */ const invalidate = () => { _data.set(emptyValue); fetch(); }; /** * Cancels all operations and resets loading states. * @public */ const abort = () => { abort$.next(); _fetchLoading.set(false); _mutationLoading.set(false); _mutationLoadingKey.set({}); }; return { state, fetch, update, withMutation, invalidate, abort, }; }