Angular Patterns - bcgov/eagle-dev-guides GitHub Wiki
Codified patterns for eagle-public (Angular 21, standalone components, signals).
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.
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) { ... }
}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 componentwithLoading 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-1project-<id>commentperiods-<projectId>table-amendments
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.
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
});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.
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);
// …
}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 guard — connectRefinementList 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.
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.
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
}
});
}