Files
NGRX-Playground/.agents/skills/angular-http/references/http-patterns.md
T
2026-03-08 08:51:02 +01:00

9.6 KiB

Angular HTTP Patterns

Table of Contents

Service Layer Pattern

Encapsulate HTTP logic in services:

import { Injectable, inject, signal, computed } from "@angular/core";
import { HttpClient } from "@angular/common/http";
import { httpResource } from "@angular/common/http";

export interface User {
  id: string;
  name: string;
  email: string;
}

@Injectable({ providedIn: "root" })
export class User {
  private http = inject(HttpClient);
  private baseUrl = "/api/users";

  // Current user ID for reactive fetching
  private currentUserId = signal<string | null>(null);

  // Reactive resource that updates when currentUserId changes
  currentUser = httpResource<User>(() => {
    const id = this.currentUserId();
    return id ? `${this.baseUrl}/${id}` : undefined;
  });

  // Set current user to fetch
  selectUser(id: string) {
    this.currentUserId.set(id);
  }

  // CRUD operations
  getAll() {
    return this.http.get<User[]>(this.baseUrl);
  }

  getById(id: string) {
    return this.http.get<User>(`${this.baseUrl}/${id}`);
  }

  create(user: Omit<User, "id">) {
    return this.http.post<User>(this.baseUrl, user);
  }

  update(id: string, user: Partial<User>) {
    return this.http.patch<User>(`${this.baseUrl}/${id}`, user);
  }

  delete(id: string) {
    return this.http.delete<void>(`${this.baseUrl}/${id}`);
  }
}

Caching Strategies

Simple In-Memory Cache

@Injectable({ providedIn: "root" })
export class CachedUser {
  private http = inject(HttpClient);
  private cache = new Map<string, { data: User; timestamp: number }>();
  private cacheDuration = 5 * 60 * 1000; // 5 minutes

  getUser(id: string): Observable<User> {
    const cached = this.cache.get(id);

    if (cached && Date.now() - cached.timestamp < this.cacheDuration) {
      return of(cached.data);
    }

    return this.http.get<User>(`/api/users/${id}`).pipe(
      tap((user) => {
        this.cache.set(id, { data: user, timestamp: Date.now() });
      }),
    );
  }

  invalidateCache(id?: string) {
    if (id) {
      this.cache.delete(id);
    } else {
      this.cache.clear();
    }
  }
}

Signal-Based Cache

@Injectable({ providedIn: "root" })
export class UserCache {
  private http = inject(HttpClient);

  // Cache as signal
  private usersCache = signal<Map<string, User>>(new Map());

  // Computed for easy access
  users = computed(() => Array.from(this.usersCache().values()));

  getUser(id: string): User | undefined {
    return this.usersCache().get(id);
  }

  async fetchUser(id: string): Promise<User> {
    const cached = this.getUser(id);
    if (cached) return cached;

    const user = await firstValueFrom(this.http.get<User>(`/api/users/${id}`));

    this.usersCache.update((cache) => {
      const newCache = new Map(cache);
      newCache.set(id, user);
      return newCache;
    });

    return user;
  }
}

Pagination

Paginated Resource

interface PaginatedResponse<T> {
  data: T[];
  total: number;
  page: number;
  pageSize: number;
  totalPages: number;
}

@Component({
  template: `
    @if (usersResource.isLoading()) {
      <app-spinner />
    } @else if (usersResource.hasValue()) {
      <ul>
        @for (user of usersResource.value().data; track user.id) {
          <li>{{ user.name }}</li>
        }
      </ul>

      <div class="pagination">
        <button (click)="prevPage()" [disabled]="page() === 1">Previous</button>

        <span>Page {{ page() }} of {{ usersResource.value().totalPages }}</span>

        <button
          (click)="nextPage()"
          [disabled]="page() >= usersResource.value().totalPages"
        >
          Next
        </button>
      </div>
    }
  `,
})
export class UsersList {
  page = signal(1);
  pageSize = signal(10);

  usersResource = httpResource<PaginatedResponse<User>>(() => ({
    url: "/api/users",
    params: {
      page: this.page().toString(),
      pageSize: this.pageSize().toString(),
    },
  }));

  nextPage() {
    this.page.update((p) => p + 1);
  }

  prevPage() {
    this.page.update((p) => Math.max(1, p - 1));
  }
}

Infinite Scroll

@Component({
  template: `
    <ul>
      @for (user of allUsers(); track user.id) {
        <li>{{ user.name }}</li>
      }
    </ul>

    @if (isLoading()) {
      <app-spinner />
    }

    @if (hasMore()) {
      <button (click)="loadMore()">Load More</button>
    }
  `,
})
export class InfiniteUsers {
  private http = inject(HttpClient);

  private page = signal(1);
  private users = signal<User[]>([]);
  private totalPages = signal(1);

  allUsers = this.users.asReadonly();
  isLoading = signal(false);
  hasMore = computed(() => this.page() < this.totalPages());

  constructor() {
    this.loadPage(1);
  }

  loadMore() {
    this.loadPage(this.page() + 1);
  }

  private async loadPage(page: number) {
    this.isLoading.set(true);

    try {
      const response = await firstValueFrom(
        this.http.get<PaginatedResponse<User>>("/api/users", {
          params: { page: page.toString(), pageSize: "20" },
        }),
      );

      this.users.update((users) => [...users, ...response.data]);
      this.page.set(page);
      this.totalPages.set(response.totalPages);
    } finally {
      this.isLoading.set(false);
    }
  }
}

File Upload

Single File Upload

@Component({
  template: `
    <input type="file" (change)="onFileSelected($event)" />

    @if (uploadProgress() !== null) {
      <progress [value]="uploadProgress()" max="100"></progress>
    }
  `,
})
export class FileUpload {
  private http = inject(HttpClient);

  uploadProgress = signal<number | null>(null);

  onFileSelected(event: Event) {
    const file = (event.target as HTMLInputElement).files?.[0];
    if (!file) return;

    const formData = new FormData();
    formData.append("file", file);

    this.http
      .post("/api/upload", formData, {
        reportProgress: true,
        observe: "events",
      })
      .subscribe((event) => {
        if (event.type === HttpEventType.UploadProgress && event.total) {
          this.uploadProgress.set(
            Math.round((100 * event.loaded) / event.total),
          );
        } else if (event.type === HttpEventType.Response) {
          this.uploadProgress.set(null);
          console.log("Upload complete:", event.body);
        }
      });
  }
}

Multiple Files

uploadFiles(files: FileList) {
  const formData = new FormData();

  for (let i = 0; i < files.length; i++) {
    formData.append('files', files[i]);
  }

  return this.http.post<{ urls: string[] }>('/api/upload-multiple', formData);
}

Request Cancellation

With resource()

// resource() automatically handles cancellation via abortSignal
searchResource = resource({
  params: () => ({ q: this.query() }),
  loader: async ({ params, abortSignal }) => {
    const response = await fetch(`/api/search?q=${params.q}`, {
      signal: abortSignal, // Cancels if params change
    });
    return response.json();
  },
});

With HttpClient

@Component({...})
export class Search implements OnDestroy {
  private http = inject(HttpClient);
  private destroyRef = inject(DestroyRef);

  query = signal('');
  results = signal<Result[]>([]);

  private searchSubscription?: Subscription;

  search() {
    // Cancel previous request
    this.searchSubscription?.unsubscribe();

    this.searchSubscription = this.http
      .get<Result[]>(`/api/search?q=${this.query()}`)
      .pipe(takeUntilDestroyed(this.destroyRef))
      .subscribe(results => this.results.set(results));
  }
}
@Component({...})
export class SearchDebounced {
  query = signal('');

  private http = inject(HttpClient);

  results = toSignal(
    toObservable(this.query).pipe(
      debounceTime(300),
      distinctUntilChanged(),
      filter(q => q.length >= 2),
      switchMap(q => this.http.get<Result[]>(`/api/search?q=${q}`)),
      catchError(() => of([]))
    ),
    { initialValue: [] }
  );
}

Testing HTTP

Testing httpResource

describe("UserCmpt", () => {
  let component: UserCmpt;
  let httpMock: HttpTestingController;

  beforeEach(() => {
    TestBed.configureTestingModule({
      imports: [UserCmpt],
      providers: [provideHttpClientTesting()],
    });

    component = TestBed.createComponent(UserCmpt).componentInstance;
    httpMock = TestBed.inject(HttpTestingController);
  });

  it("should load user", () => {
    component.userId.set("123");

    const req = httpMock.expectOne("/api/users/123");
    req.flush({ id: "123", name: "Test User" });

    expect(component.userResource.value()?.name).toBe("Test User");
  });

  afterEach(() => {
    httpMock.verify();
  });
});

Testing Services

describe("User", () => {
  let service: User;
  let httpMock: HttpTestingController;

  beforeEach(() => {
    TestBed.configureTestingModule({
      providers: [User, provideHttpClient(), provideHttpClientTesting()],
    });

    service = TestBed.inject(User);
    httpMock = TestBed.inject(HttpTestingController);
  });

  it("should create user", () => {
    const newUser = { name: "Test", email: "test@example.com" };

    service.create(newUser).subscribe((user) => {
      expect(user.id).toBeDefined();
      expect(user.name).toBe("Test");
    });

    const req = httpMock.expectOne("/api/users");
    expect(req.request.method).toBe("POST");
    expect(req.request.body).toEqual(newUser);

    req.flush({ id: "1", ...newUser });
  });
});