Angular Post List — Pagination, Filtering and State Management

The public post list is the most-visited page in the BlogApp — it must load fast, support filtering and pagination, and persist state in the URL so users can share and bookmark filtered views. URL-persisted state (using Angular’s router query parameters) is the key pattern: when a user filters by “dotnet” category and paginates to page 3, the URL becomes /posts?category=dotnet&page=3. Bookmarking this URL, sharing it, or refreshing the page all restore the exact same view.

Post List with URL-Persisted State

@Component({
  selector:   'app-post-list',
  standalone:  true,
  imports:    [RouterLink, MatPaginatorModule, MatChipsModule, AsyncPipe],
  template: `
    <!-- Category filter sidebar ──────────────────────────────────────────── -->
    <aside class="sidebar">
      <h3>Categories</h3>
      @for (cat of categories(); track cat.slug) {
        <a [routerLink]="[]"
           [queryParams]="{ category: cat.slug, page: 1 }"
           queryParamsHandling="merge"
           [class.active]="activeCategory() === cat.slug">
          {{ cat.name }} ({{ cat.postCount }})
        </a>
      }
    </aside>

    <!-- Post grid ────────────────────────────────────────────────────────── -->
    <main>
      <!-- Search ────────────────────────────────────────────────────────── -->
      <input [formControl]="searchControl" placeholder="Search posts...">

      @if (loading()) {
        <!-- Skeleton cards while loading ──────────────────────────────── -->
        @for (_ of skeletons; track $index) { <app-post-card-skeleton /> }
      } @else if (error()) {
        <p class="error">Failed to load posts. <a (click)="reload()">Try again</a></p>
      } @else if (posts().length === 0) {
        <p class="empty">No posts found. <a [routerLink]="[]" [queryParams]="{ category: null, search: null }">Clear filters</a></p>
      } @else {
        <div class="post-grid">
          @for (post of posts(); track post.id) {
            <app-post-card [post]="post" />
          }
        </div>
        <mat-paginator
          [length]="total()"
          [pageSize]="pageSize"
          [pageIndex]="currentPage() - 1"
          (page)="onPageChange($event)">
        </mat-paginator>
      }
    </main>
  `,
})
export class PostListComponent implements OnInit {
  private postsApi   = inject(PostsApiService);
  private route      = inject(ActivatedRoute);
  private router     = inject(Router);
  private destroyRef = inject(DestroyRef);

  posts           = signal<PostSummaryDto[]>([]);
  total           = signal(0);
  loading         = signal(true);
  error           = signal(false);
  activeCategory  = signal<string | null>(null);
  currentPage     = signal(1);
  skeletons       = Array(6);  // 6 skeleton cards during loading
  searchControl   = new FormControl('');
  readonly pageSize = 10;

  ngOnInit() {
    // React to URL query param changes
    this.route.queryParamMap.pipe(
      takeUntilDestroyed(this.destroyRef),
      switchMap(params => {
        const page     = Number(params.get('page') ?? 1);
        const category = params.get('category') ?? undefined;
        const search   = params.get('search')   ?? undefined;

        this.currentPage.set(page);
        this.activeCategory.set(category ?? null);
        this.loading.set(true);
        this.error.set(false);

        return this.postsApi.getPublished(page, this.pageSize, category, search).pipe(
          catchError(() => { this.error.set(true); return EMPTY; })
        );
      }),
    ).subscribe(result => {
      this.posts.set(result.items);
      this.total.set(result.total);
      this.loading.set(false);
    });

    // Debounced search → update URL → triggers reload
    this.searchControl.valueChanges.pipe(
      debounceTime(350),
      distinctUntilChanged(),
      takeUntilDestroyed(this.destroyRef),
    ).subscribe(search => this.updateUrl({ search: search || null, page: 1 }));
  }

  onPageChange(event: PageEvent): void {
    this.updateUrl({ page: event.pageIndex + 1 });
    window.scrollTo({ top: 0, behavior: 'smooth' });
  }

  private updateUrl(params: Record<string, any>): void {
    this.router.navigate([], {
      relativeTo: this.route,
      queryParams: params,
      queryParamsHandling: 'merge',
    });
  }

  reload(): void { this.route.queryParamMap.pipe(take(1)).subscribe(); }
}
Note: The switchMap on route.queryParamMap automatically cancels the previous API call when query parameters change — if the user quickly changes from “dotnet” to “angular” category, only the “angular” request completes. This prevents stale responses from overwriting newer filter results. The URL change (from the category sidebar click) triggers the queryParamMap Observable, which switches to a new API request with the updated parameters.
Tip: Use queryParamsHandling: 'merge' when updating query parameters so existing parameters are preserved. When the user clicks to page 2, only the page param changes — the category and search params remain. Without merge, clicking page 2 would clear the active category filter. Merge is the correct default for any multi-parameter URL state management where parameters are independently controlled.
Warning: Avoid loading state in the component class using multiple boolean flags (isLoading, hasError, isEmpty). Use a discriminated union state pattern instead or separate signals. Multiple flags can conflict — isLoading = false and hasError = false and posts.length = 0 all simultaneously is ambiguous (initial state vs genuinely empty results). The template’s @if (loading()) / @else if (error()) / @else if (posts().length === 0) hierarchy handles this correctly with mutually exclusive states.

Common Mistakes

Mistake 1 — Client-side pagination on a large dataset (loads all data)

❌ Wrong — fetching all posts and paginating in Angular; API returns 10,000 rows; slow and wasteful.

✅ Correct — server-side pagination: API uses OFFSET/FETCH; Angular sends page/size params; receives only the current page.

Mistake 2 — Not persisting filter state in URL (lost on page refresh)

❌ Wrong — filter state in component signals only; user refreshes page; filter resets; bookmarked filtered view is broken.

✅ Correct — filters in URL query params via router.navigate([], { queryParams }); refresh restores state.

🧠 Test Yourself

The search input uses debounceTime(350) and updates the URL on each change. The user types “dotnet” (6 keystrokes). How many API calls are made?