Angular Patterns - bcgov/eagle-dev-guides GitHub Wiki

Angular Patterns

Codified patterns for eagle-public (Angular 21, standalone components, signals).

Constructor-First API Calls

Make API calls in the constructor, not ngOnInit. The constructor runs one change-detection cycle earlier, which eliminates the blank-state flash before data arrives.

// ✅ Correct — fetch in constructor
export class MyComponent implements OnDestroy {
  private service = inject(MyService);
  private destroy$ = new Subject<void>();

  public data = signal<Item[] | null>(null);

  constructor() {
    this.service.getAll()
      .pipe(takeUntil(this.destroy$))
      .subscribe({
        next: items => this.data.set(items),
        error: () => this.data.set([])
      });
  }

  ngOnDestroy() { this.destroy$.next(); this.destroy$.complete(); }
}

// ❌ Avoid — fetching in ngOnInit is one lifecycle tick later
export class MyComponent implements OnInit {
  ngOnInit() {
    this.service.getAll().subscribe(...);
  }
}

Reserve ngOnInit for DOM-dependent setup (e.g., L.DomEvent.disable* on Leaflet nodes) that genuinely requires the view to be rendered first.

Null-Sentinel Loading Pattern

Use signal<T | null>(null) to distinguish "not yet loaded" from "loaded but empty". An initial [] causes a flash of "no results found" before data arrives.

// null = loading, [] = loaded + empty, T[] = loaded with data
public projects = signal<Project[] | null>(null);

// Propagate null through computed chains
public filtered = computed(() => {
  const items = this.projects();
  return items === null ? null : this.filterService.filter(items);
});

In templates:

@if (projects() === null) {
  <app-loading-spinner />
} @else if (projects()!.length === 0) {
  <p>No results found.</p>
} @else {
  @for (p of projects(); track p._id) { ... }
}

Loading State — Services Own It, Components Read It

LoadingStateService is the single source of truth. Services manage loading lifecycle via the withLoading operator from shared/utils/rxjs-operators. Components observe state but never write it.

import { withLoading } from 'app/shared/utils/rxjs-operators';

// ✅ Service — use withLoading (calls startLoading on subscribe, stopLoading on finalize)
@Injectable({ providedIn: 'root' })
export class ProjectService {
  private loadingState = inject(LoadingStateService);

  getAllFull(): Observable<Project[]> {
    return this.api.getProjects().pipe(
      withLoading(this.loadingState, 'projects-full-page-1'),
      map(res => res.data),
      catchError(err => this.api.handleError(err))
    );
  }
}

// ✅ Component — reads only
export class MapComponent {
  private loadingState = inject(LoadingStateService);
  public loading = this.loadingState.getOperationState('projects-full-page-1');
}

// ❌ Never call startLoading / stopLoading manually in a service
// ❌ Never call startLoading / stopLoading from a component

withLoading uses defer() so startLoading fires on subscription, not on observable construction. This prevents spurious spinners.

Loading operation IDs follow the pattern <noun>-<qualifier>, e.g.:

  • projects-full-page-1
  • project-<id>
  • commentperiods-<projectId>
  • table-amendments

Reused Component Instances — switchMap + debounceTime

When a component is created once and reused (e.g., a Leaflet popup created with viewContainerRef.createComponent() and updated via setInput()), ngOnDestroy never fires between uses. A takeUntil(destroy$) guard inside an effect() therefore accumulates live subscriptions — one per reuse — eventually freezing the tab.

Fix: One Subject driven by an effect(), piped through debounceTime(0) and switchMap, set up once in the constructor.

export class ReusedPopupComponent implements OnDestroy {
  item = input.required<Item>();

  private destroy$ = new Subject<void>();
  private itemId$ = new Subject<string>();

  constructor() {
    // switchMap cancels previous in-flight request when item changes.
    // debounceTime(0) collapses synchronous burst emissions to one per tick.
    this.itemId$.pipe(
      debounceTime(0),
      switchMap(id => this.service.getById(id)),
      takeUntil(this.destroy$)
    ).subscribe({
      next: data => this.data.set(data),
      error: err => console.error(err)
    });

    // effect() only drives the Subject — never subscribes itself
    effect(() => {
      const id = this.item()._id;
      if (id) this.itemId$.next(id);
    });
  }

  ngOnDestroy() { this.destroy$.next(); this.destroy$.complete(); }
}

Why debounceTime(0): Angular's reactive graph can emit a signal value multiple times within a single change-detection pass (especially when a parent is updating a large collection of reactive state). Without debounce, each intermediate emission starts an HTTP request that is immediately cancelled by switchMap, flooding the network log.

Signal Batching — Avoid O(n) Signal Sets

Every call to signal.set() synchronously notifies all consumers. Calling it inside a loop creates O(n) reactive updates and intermediate states that may trigger effects prematurely.

// ✅ Build the new value first, set the signal once
updateVisibilities(updates: Map<string, boolean>): void {
  const current = new Map(this.items());
  let changed = false;
  updates.forEach((visible, id) => {
    const state = current.get(id);
    if (state && state.visible !== visible) {
      current.set(id, { ...state, visible });
      changed = true;
    }
  });
  if (changed) this.items.set(current); // ← one notification
}

// ❌ N notifications, N intermediate states
items.forEach(item => {
  const current = new Map(this.items());
  current.set(item.id, { ...item, visible: true });
  this.items.set(current); // fires effects on every iteration
});

Input Signals for @Input Replacement

Prefer input() / input.required() over @Input() for components that need to react to new values. Combine with effect() + untracked() for side effects:

export class DetailComponent {
  // Replaces @Input() project: Project
  project = input.required<Project>();

  constructor() {
    effect(() => {
      const p = this.project();           // tracked — re-runs when project changes
      untracked(() => this.loadExtra(p)); // untracked — side effect, no new dependency
    });
  }
}

Drive it from the parent with componentRef.setInput('project', value) rather than direct property assignment, which bypasses Angular's change detection.

Two-Phase Loading — Emit Partial Then Enrich

When a service call returns a primary record quickly but needs secondary API calls to enrich it (e.g., resolving staff names from a User service), emit the primary record immediately so the page renders while enrichment happens in the background.

Pattern: Use RxJS concat(of(partial), enrichment$) — the observable emits twice. Call stopLoading() before returning the concat so the spinner clears on the first emission, not the second.

import { concat, of } from 'rxjs';

getById(id: string): Observable<Project> {
  const loadingId = `project-${id}`;
  this.loadingState.startLoading(loadingId);

  return this.api.getProject(id).pipe(
    flatMap(project => {
      // Fast path: no enrichment needed
      if (project.projectLeadId == null && project.responsibleEPDId == null) {
        this.loadingState.stopLoading(loadingId);
        return of(new Project(project));
      }

      const partialProject = new Project(project);
      // Stop loading BEFORE returning concat — spinner clears on first emission
      this.loadingState.stopLoading(loadingId);
      return concat(
        of(partialProject),                    // emits immediately → page renders
        this._getExtraAppData(partialProject, { // emits after User API calls
          getprojectLead: !!project.projectLeadId,
          getresponsibleEPD: !!project.responsibleEPDId,
        })
      );
    })
  );
}

Consumer (component or effect) simply subscribes and sets the signal on every emission:

effect(() => {
  const id = this.routeParams().id;
  this.projectService.getById(id)
    .pipe(takeUntil(this.destroy$))
    .subscribe({
      next: project => {
        this.project.set(project);
        this.initMap();   // guard with `if (this.map) return;` — called twice
      }
    });
});

Guard double-init: If side effects in the subscription (e.g. initMap()) must only run once, add an early-return guard:

private initMap(): void {
  if (this.map) { return; }  // already initialised on first emission
  this.map = L.map(this.mapElement.nativeElement);
  // …
}

Stale-While-Revalidate Search Cache

When routing back to a search/list page, showing cached results immediately eliminates the blank-state flash while the new query is in-flight.

TypesenseService (a providedIn: 'root' singleton) holds per-index caches that survive component destroy/recreate cycles:

@Injectable({ providedIn: 'root' })
export class TypesenseService {
  private lastHitsCache:   Record<string, any[]> = {};
  private lastFacetsCache: Record<string, Record<string, any[]>> = {};

  getLastHits(index: string): any[]                         {  }
  setLastHits(index: string, hits: any[]): void             {  }
  getLastFacets(index: string, attr: string): any[]         {  }
  setLastFacets(index: string, attr: string, items: any[]): void {  }
}

On component mount — restore stale values instantly, then let live search overwrite:

constructor() {
  // Restore cached hits immediately (page renders without waiting for Typesense)
  const staleHits = this.typesenseService.getLastHits('projects');
  if (staleHits.length) this.hits.set(staleHits);

  // Restore cached facets + mark filters as ready (no spinner)
  ['region', 'type', 'sector', 'phase'].forEach(attr => {
    const cached = this.typesenseService.getLastFacets('projects', attr);
    if (cached.length) { /* populate masterMap and signal */ }
  });
  if (anyFacetsCached) this.filtersLoaded.set(true);
}

Refinement list guardconnectRefinementList fires with items = [] on the very first mount before the search response arrives. Without a guard this zeros out the cached facets:

connectRefinementList(renderOptions => {
  const { items } = renderOptions;
  // Skip empty initial callback if master map already has cached data
  if (items.length === 0 && this.masterRegion.size > 0) return;

  items.forEach(item => this.masterRegion.set(item.value, item));
  this.typesenseService.setLastFacets('projects', 'region', [...this.masterRegion.values()]);
  this.regionFacets.set([...this.masterRegion.values()]);
});

See Typesense Search for the full integration architecture.

Chaining Async Dependencies Before Route Params

Some document-tab components need config lists metadata before they can correctly respond to route queryParamMap changes. Subscribing to both independently causes a double-fetch race: the first fetch fires with an empty lists array (producing wrong query modifiers), then a second fetch fires once lists arrive — creating a visible pop-in.

Fix: Use take(1) + switchMap to chain lists → queryParamMap:

import { take, switchMap, takeWhile } from 'rxjs/operators';

constructor() {
  this.configService.lists.pipe(
    take(1),              // receive lists once, then move on
    switchMap(list => {
      this.lists = list;
      this.buildFilterArrays(list);
      this.setFilters();
      return this.route.queryParamMap; // subscribe to route changes only after lists ready
    }),
    takeWhile(() => this.alive)
  ).subscribe(() => {
    this.fetchDataWithCurrentParams();
  });
}

Applied to: CertificatesComponent, ApplicationComponent, AmendmentsComponent, DocumentsTabComponent. Each defers its first data fetch until lists are available.

effect() + untracked() — Breaking Circular Signal Dependencies

An effect() that reads a signal it also writes creates an infinite loop: the write re-triggers the effect which reads again which writes again.

Wrap the read in untracked() to break the tracked dependency while still accessing the current value:

import { effect, untracked } from '@angular/core';

constructor() {
  effect(() => {
    const results = this.tableSignal(); // ✅ tracked — effect re-runs when this changes

    if (results) {
      // ❌ Don't read tableData() here — creates circular dependency
      // const current = this.tableData();

      // ✅ untracked() reads current value without registering a new dependency
      const current = untracked(() => this.tableData());

      this.tableData.set(buildFrom(current, results)); // writing is fine
    }
  });
}
⚠️ **GitHub.com Fallback** ⚠️