Angular Integration
fetchquack provides seamless Angular integration with RxJS Observable support and full dependency injection context for interceptors.
Installation
Section titled “Installation”npm install fetchquackfetchquack requires:
- Angular 17.0.0 or higher
- RxJS 7.0.0 or higher
These are peer dependencies and should already be in your Angular project.
Configure fetchquack in your app.config.ts:
import { ApplicationConfig } from '@angular/core';import { provideNgxHttpClient } from 'fetchquack/ngx';
export const appConfig: ApplicationConfig = { providers: [ provideNgxHttpClient({ globalInterceptors: [ // Add your interceptors here ] }) ]};Calling provideNgxHttpClient() is optional. Without it, NgxHttpClient still works but without global interceptors. When called, it registers a configured HttpClient instance with Angular’s DI system.
Using NgxHttpClient
Section titled “Using NgxHttpClient”Inject NgxHttpClient in your components or services:
import { Component, inject, signal } from '@angular/core';import { NgxHttpClient } from 'fetchquack/ngx';
@Component({ selector: 'app-user-list', template: ` <div *ngFor="let user of users()"> {{ user.name }} </div> `})export class UserListComponent { private http = inject(NgxHttpClient); users = signal<User[]>([]);
async ngOnInit() { const users = await this.http.fetch<User[]>({ method: 'GET', url: '/api/users' }); this.users.set(users); }}Promise-based Requests
Section titled “Promise-based Requests”Use async/await for simple requests. This is the default behavior:
@Component({...})export class UserComponent { private http = inject(NgxHttpClient); user = signal<User | null>(null);
async loadUser(id: number) { try { const user = await this.http.fetch<User>({ method: 'GET', url: `/api/users/${id}` }); this.user.set(user); } catch (error) { console.error('Failed to load user:', error); } }
async createUser(userData: CreateUserDto) { return this.http.fetch<User>({ method: 'POST', url: '/api/users', body: userData }); }}Observable-based Requests
Section titled “Observable-based Requests”Add returnObservable: true to get an Observable instead of a Promise. The Observable automatically aborts the request when unsubscribed:
import { DestroyRef, inject } from '@angular/core';import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
@Component({...})export class UserComponent { private http = inject(NgxHttpClient); private destroyRef = inject(DestroyRef); user = signal<User | null>(null);
loadUser(id: number) { this.http.fetch<User>({ method: 'GET', url: `/api/users/${id}`, returnObservable: true // Returns Observable<User> }).pipe( takeUntilDestroyed(this.destroyRef) // Auto-cleanup and abort on destroy ).subscribe({ next: (user) => this.user.set(user), error: (err) => console.error(err) }); }}Streaming Responses
Section titled “Streaming Responses”fetchStream() always returns an Observable. Each emission is a chunk of data. Unsubscribing automatically aborts the stream:
@Component({ selector: 'app-ai-chat', template: `<div>{{ response() }}</div>`})export class AiChatComponent { private http = inject(NgxHttpClient); private destroyRef = inject(DestroyRef); response = signal('');
streamAiResponse(prompt: string) { this.response.set('');
this.http.fetchStream({ method: 'POST', url: '/api/ai/chat', body: { prompt }, decodeToString: true }).pipe( takeUntilDestroyed(this.destroyRef) ).subscribe({ next: (chunk) => { this.response.update(text => text + chunk); }, error: (err) => console.error(err), complete: () => console.log('Stream complete') }); }}Server-Sent Events
Section titled “Server-Sent Events”sse() always returns an Observable<SseEvent<T>>. Each emission is a parsed SSE event. Unsubscribing automatically closes the connection:
@Component({ selector: 'app-notifications', template: ` <div *ngFor="let n of notifications()"> {{ n.message }} </div> `})export class NotificationsComponent { private http = inject(NgxHttpClient); private destroyRef = inject(DestroyRef); notifications = signal<Notification[]>([]);
ngOnInit() { this.http.sse<Notification>({ method: 'GET', url: '/api/notifications', parseJson: true, autoReconnect: true }).pipe( takeUntilDestroyed(this.destroyRef) ).subscribe({ next: (event) => { if (event.data) { this.notifications.update(list => [...list, event.data!]); } }, error: (err) => console.error('SSE error:', err) }); }}Interceptors with Dependency Injection
Section titled “Interceptors with Dependency Injection”The key advantage of the Angular wrapper: interceptors can use Angular’s inject() function to access services:
import { inject } from '@angular/core';import { HttpInterceptorFn } from 'fetchquack';import { AuthService } from './auth.service';
export const authInterceptor: HttpInterceptorFn = async (context, next) => { // inject() works here because provideNgxHttpClient wraps // interceptors in Angular's injection context const authService = inject(AuthService); const token = await authService.getToken();
if (token) { context.headers['Authorization'] = `Bearer ${token}`; }
return next(context);};
// error-handler.interceptor.tsimport { inject } from '@angular/core';import { Router } from '@angular/router';import { HttpInterceptorFn, HttpError } from 'fetchquack';
export const errorHandlerInterceptor: HttpInterceptorFn = async (context, next) => { const router = inject(Router);
try { return await next(context); } catch (error) { if (error instanceof HttpError && error.statusCode === 401) { router.navigate(['/login']); } throw error; }};
// app.config.tsimport { provideNgxHttpClient } from 'fetchquack/ngx';import { authInterceptor } from './interceptors/auth.interceptor';import { errorHandlerInterceptor } from './interceptors/error-handler.interceptor';
export const appConfig: ApplicationConfig = { providers: [ provideNgxHttpClient({ globalInterceptors: [ authInterceptor, errorHandlerInterceptor ] }) ]};Both global and per-request interceptors are wrapped in Angular’s injection context, so inject() works in both cases.
Progress Tracking
Section titled “Progress Tracking”Progress tracking works the same as the base library since fetch() returns a Promise:
@Component({ selector: 'app-file-upload', template: ` <input type="file" (change)="onFileSelected($event)" /> <button (click)="upload()">Upload</button> <div>{{ uploadProgress() }}%</div> `})export class FileUploadComponent { private http = inject(NgxHttpClient); selectedFile = signal<File | null>(null); uploadProgress = signal(0);
onFileSelected(event: Event) { const input = event.target as HTMLInputElement; if (input.files?.length) { this.selectedFile.set(input.files[0]); } }
async upload() { const file = this.selectedFile(); if (!file) return;
await this.http.fetch({ method: 'POST', url: '/api/upload', body: file, headers: { 'Content-Type': file.type }, onUploadProgress: (progress) => { if (progress.percentage !== undefined) { this.uploadProgress.set(Math.round(progress.percentage)); } } });
console.log('Upload complete!'); }}Service Pattern
Section titled “Service Pattern”Create reusable services for your API:
import { Injectable, inject } from '@angular/core';import { NgxHttpClient } from 'fetchquack/ngx';
@Injectable({ providedIn: 'root' })export class UserService { private http = inject(NgxHttpClient);
getUser(id: number) { return this.http.fetch<User>({ method: 'GET', url: `/api/users/${id}` }); }
getUsers() { return this.http.fetch<User[]>({ method: 'GET', url: '/api/users' }); }
createUser(userData: CreateUserDto) { return this.http.fetch<User>({ method: 'POST', url: '/api/users', body: userData }); }
updateUser(id: number, updates: Partial<User>) { return this.http.fetch<User>({ method: 'PATCH', url: `/api/users/${id}`, body: updates }); }
deleteUser(id: number) { return this.http.fetch<void>({ method: 'DELETE', url: `/api/users/${id}`, parseJson: false }); }}
// Component usage@Component({...})export class UserComponent { private userService = inject(UserService); user = signal<User | null>(null);
async loadUser(id: number) { this.user.set(await this.userService.getUser(id)); }}Error Handling with RxJS
Section titled “Error Handling with RxJS”Handle errors with RxJS operators in Observable mode:
import { catchError, throwError } from 'rxjs';import { HttpError } from 'fetchquack';
@Component({...})export class DataComponent { private http = inject(NgxHttpClient); private destroyRef = inject(DestroyRef);
loadData() { this.http.fetch<Data>({ method: 'GET', url: '/api/data', returnObservable: true }).pipe( catchError((error: HttpError) => { if (error.statusCode === 404) { this.showNotFound(); } else { this.showError(error.message); } return throwError(() => error); }), takeUntilDestroyed(this.destroyRef) ).subscribe(data => this.processData(data)); }}Or use try/catch with Promise mode:
async loadData() { try { const data = await this.http.fetch<Data>({ method: 'GET', url: '/api/data' }); this.processData(data); } catch (error) { if (error instanceof HttpError) { this.showError(error.message); } }}Testing
Section titled “Testing”Mock NgxHttpClient in tests:
import { TestBed } from '@angular/core/testing';import { NgxHttpClient } from 'fetchquack/ngx';import { UserService } from './user.service';
describe('UserService', () => { let service: UserService; let httpMock: jasmine.SpyObj<NgxHttpClient>;
beforeEach(() => { const spy = jasmine.createSpyObj('NgxHttpClient', ['fetch', 'fetchStream', 'sse']);
TestBed.configureTestingModule({ providers: [ UserService, { provide: NgxHttpClient, useValue: spy } ] });
service = TestBed.inject(UserService); httpMock = TestBed.inject(NgxHttpClient) as jasmine.SpyObj<NgxHttpClient>; });
it('should get user by id', async () => { const mockUser = { id: 1, name: 'Test User' }; httpMock.fetch.and.returnValue(Promise.resolve(mockUser));
const user = await service.getUser(1);
expect(user).toEqual(mockUser); expect(httpMock.fetch).toHaveBeenCalledWith({ method: 'GET', url: '/api/users/1' }); });});Best Practices
Section titled “Best Practices”Use takeUntilDestroyed()
Section titled “Use takeUntilDestroyed()”Always use takeUntilDestroyed() with Observables to avoid memory leaks.
When calling takeUntilDestroyed() outside a constructor or field initializer, you must inject DestroyRef and pass it explicitly:
// In constructor or field initializer — no argument neededprivate data$ = this.http.sse({...}) .pipe(takeUntilDestroyed());
// In methods like ngOnInit, loadData, etc. — pass DestroyRefprivate destroyRef = inject(DestroyRef);
ngOnInit() { this.http.sse({...}) .pipe(takeUntilDestroyed(this.destroyRef)) .subscribe(...);}Prefer Promises for Simple Cases
Section titled “Prefer Promises for Simple Cases”Use Promises for one-off requests, Observables for streams and reactive patterns:
// Good - simple request with async/awaitasync loadData() { const data = await this.http.fetch<Data>({...}); this.data.set(data);}
// Overkill for a simple one-off requestloadData() { this.http.fetch<Data>({ method: 'GET', url: '/api/data', returnObservable: true }) .pipe(takeUntilDestroyed(this.destroyRef)) .subscribe(data => this.data.set(data));}Use Services for API Logic
Section titled “Use Services for API Logic”Keep API logic in services, not in components:
// Good - API logic in a service@Injectable({ providedIn: 'root' })export class ApiService { private http = inject(NgxHttpClient); getUsers() { return this.http.fetch<User[]>({...}); }}
// Avoid - API logic directly in component@Component({...})export class UserComponent { private http = inject(NgxHttpClient); async loadUsers() { return this.http.fetch<User[]>({...}); }}Next Steps
Section titled “Next Steps”- Learn about Interceptors for advanced middleware
- Explore Streaming for real-time data
- Check out Server-Sent Events for push notifications