Skip to main content
Air Tracker uses modern Angular patterns including standalone components, signals, and the inject function. This guide covers best practices for component development.

Standalone Components

All components in Air Tracker are standalone components (not using NgModules).

Creating a Standalone Component

Example from flights-shell.component.ts:40-55:
import { Component } from '@angular/core';
import { MatGridListModule } from '@angular/material/grid-list';
import { FlightsListComponent } from '../flights-list/flights-list.component';

@Component({
  selector: 'app-flights-shell',
  imports: [
    FlightsListComponent,
    MatGridListModule,
    MatProgressBarModule,
    // ... other imports
  ],
  templateUrl: './flights-shell.component.html',
  styleUrl: './flights-shell.component.scss',
})
export class FlightsShellComponent {
  // Component logic
}

Key Points

  • No standalone: true needed (defaults to true in Angular 20+)
  • Import all dependencies directly in the imports array
  • Import other standalone components, directives, and pipes
  • Import Angular modules (like MatGridListModule)

Generate Standalone Components

ng generate component features/my-feature/my-component
The Angular CLI automatically generates standalone components by default.

Component Structure

File Organization

Each component has its own directory:
flights-shell/
├── flights-shell.component.ts       # Component logic
├── flights-shell.component.html     # Template
├── flights-shell.component.scss     # Styles
└── flights-shell.component.spec.ts  # Tests

Component Class Structure

Follow this consistent structure (example from flights-shell.component.ts:56-104):
@Component({
  selector: 'app-flights-shell',
  imports: [/* ... */],
  templateUrl: './flights-shell.component.html',
  styleUrl: './flights-shell.component.scss',
})
export class FlightsShellComponent implements OnInit {
  // 1. Injected services (readonly)
  protected readonly store = inject(FlightsStoreService);
  private readonly overlay = inject(Overlay);
  protected readonly viewport = inject(ViewportService);

  // 2. Component state
  private overlayRef: OverlayRef | null = null;
  private lastSelectedId: string | null = null;

  // 3. Constructor (effects and reactive setup)
  constructor() {
    effect(() => {
      // Reactive logic
    });
  }

  // 4. Lifecycle hooks
  ngOnInit(): void {
    this.store.startSmartPolling();
  }

  // 5. Public methods (called from template)
  handleAction(): void {
    // ...
  }

  // 6. Private methods
  private helperMethod(): void {
    // ...
  }
}

Dependency Injection

Using inject() Function

Air Tracker uses the modern inject() function instead of constructor injection:
// ✅ Correct - Modern approach
export class MyComponent {
  private readonly service = inject(MyService);
  protected readonly viewport = inject(ViewportService);
}

// ❌ Avoid - Old approach
export class MyComponent {
  constructor(
    private service: MyService,
    protected viewport: ViewportService
  ) {}
}

Access Modifiers for Injected Services

  • private readonly: Service not used in template
  • protected readonly: Service used in template
  • Always use readonly for injected dependencies
Example from flights-shell.component.ts:57-62:
export class FlightsShellComponent {
  // Used in template - protected
  protected readonly store = inject(FlightsStoreService);
  protected readonly viewport = inject(ViewportService);
  
  // Not used in template - private
  private readonly overlay = inject(Overlay);
  private readonly api = inject(FlightsApiService);
}

Signal Usage

Air Tracker extensively uses Angular signals for reactive state management.

Input Signals

Use input signals instead of traditional @Input() decorators. Example from polling-status.component.ts:15-20:
import { Component, input } from '@angular/core';

export class PollingStatusComponent {
  lastUpdate = input<Date | null>(null);
  nextUpdateAtMs = input<number | null>(null);
  intervalMs = input<number>(8000);
  loading = input<boolean>(false);
}

Setting Input Signals

In parent templates:
<app-polling-status
  [lastUpdate]="store.lastUpdated()"
  [nextUpdateAtMs]="store.nextUpdateAtMs()"
  [intervalMs]="store.currentPollingIntervalMs()"
  [loading]="store.loading()"
/>
In tests:
fixture.componentRef.setInput('intervalMs', 8000);
fixture.componentRef.setInput('loading', true);

Computed Signals

Use computed signals for derived state. Example from polling-status.component.ts:38-60:
import { Component, computed, signal } from '@angular/core';

export class PollingStatusComponent {
  private readonly now = signal(Date.now());
  
  // Derived from inputs and now signal
  readonly remainingMs = computed(() => {
    const at = this.nextUpdateAtMs();
    if (!at) return null;
    return Math.max(0, at - this.now());
  });

  readonly remainingSec = computed(() => {
    const ms = this.remainingMs();
    if (ms === null) return null;
    return Math.ceil(ms / 1000);
  });

  readonly progressValue = computed(() => {
    const rem = this.remainingMs();
    const interval = this.intervalMs();
    if (rem === null || interval <= 0) return null;
    
    const done = 1 - rem / interval;
    return Math.min(1, Math.max(0, done)) * 100;
  });
}

Writable Signals

Use writable signals for component state:
import { signal } from '@angular/core';

export class MyComponent {
  // Private writable signal
  private readonly _count = signal(0);
  
  // Public readonly accessor
  readonly count = this._count.asReadonly();
  
  increment(): void {
    this._count.update(current => current + 1);
  }
  
  reset(): void {
    this._count.set(0);
  }
}

Effects

Use effects for reactive side effects in the constructor. Example from flights-shell.component.ts:72-100:
import { Component, effect } from '@angular/core';

export class FlightsShellComponent {
  private lastSelectedId: string | null = null;
  private lastViewportMode: string | null = null;

  constructor() {
    // Effect runs when signals change
    effect(() => {
      const selectedId = this.store.selectedFlightId();
      const viewportMode = this.viewport.mode();

      // Only react to changes
      const changed =
        selectedId !== this.lastSelectedId || 
        viewportMode !== this.lastViewportMode;

      if (!changed) return;

      this.lastSelectedId = selectedId;
      this.lastViewportMode = viewportMode;

      if (selectedId) {
        this.openFlightDetailUI(selectedId);
      } else {
        this.closeFlightDetailUI();
      }
    });
    
    // Multiple effects can coexist
    effect(() => {
      const selectedId = this.store.selectedFlightId();
      if (!selectedId) return;

      const visible = this.store.filteredFlights()
        .some(f => f.icao24 === selectedId);
      
      if (!visible) {
        this.store.clearSelection();
      }
    });
  }
}

Effect Best Practices

  1. Place effects in the constructor
  2. Keep effects focused on a single concern
  3. Use local variables to track previous values for change detection
  4. Return early if signals haven’t changed

Service Integration

Store Pattern

Air Tracker uses a store pattern for state management. Example from flights-store.service.ts:13-108:
import { Injectable, signal, computed } from '@angular/core';

@Injectable({ providedIn: 'root' })
export class FlightsStoreService {
  // Private writable signals
  private readonly _flights = signal<Flight[]>([]);
  private readonly _loading = signal(false);
  private readonly _filters = signal<FlightFilters>({
    operator: null,
    onGround: 'all'
  });

  // Public readonly signals
  readonly flights = this._flights.asReadonly();
  readonly loading = this._loading.asReadonly();

  // Computed derived state
  readonly filteredFlights = computed(() => {
    const allFlights = this._flights();
    const { operator, onGround } = this._filters();
    
    return allFlights.filter(flight => {
      // Filter logic
    });
  });

  // Public actions
  updateFilters(newFilters: Partial<FlightFilters>): void {
    this._filters.update(current => ({ ...current, ...newFilters }));
  }
}

Consuming Store in Components

export class FlightsShellComponent {
  protected readonly store = inject(FlightsStoreService);
  
  ngOnInit(): void {
    this.store.startSmartPolling();
  }
}
In template:
@if (store.loading()) {
  <mat-progress-bar mode="indeterminate" />
}

@for (flight of store.filteredFlights(); track flight.icao24) {
  <app-flight-card [flight]="flight" />
}

Component Communication

Parent to Child - Input Signals

// Child component
export class FlightCardComponent {
  flight = input.required<Flight>();
  highlighted = input<boolean>(false);
}
<!-- Parent template -->
<app-flight-card
  [flight]="selectedFlight()"
  [highlighted]="true"
/>

Child to Parent - Output Events

import { Component, output } from '@angular/core';

// Child component
export class FlightCardComponent {
  clicked = output<string>();
  
  handleClick(): void {
    this.clicked.emit(this.flight().icao24);
  }
}
<!-- Parent template -->
<app-flight-card
  [flight]="flight"
  (clicked)="handleFlightClick($event)"
/>

Shared State - Services

For complex state sharing, use services with signals:
@Injectable({ providedIn: 'root' })
export class SelectionService {
  private readonly _selectedId = signal<string | null>(null);
  readonly selectedId = this._selectedId.asReadonly();
  
  select(id: string): void {
    this._selectedId.set(id);
  }
}

Lifecycle Hooks

Common Hooks

import { Component, OnInit, OnDestroy, AfterViewInit } from '@angular/core';

export class MyComponent implements OnInit, OnDestroy, AfterViewInit {
  ngOnInit(): void {
    // Initialize component
    // Good for: starting data fetch, subscriptions
  }

  ngAfterViewInit(): void {
    // After view initialization
    // Good for: DOM manipulation, third-party library init
  }

  ngOnDestroy(): void {
    // Cleanup
    // Good for: unsubscribing, clearing timers
  }
}

Using DestroyRef

Modern cleanup approach:
import { Component, DestroyRef, inject } from '@angular/core';

export class PollingStatusComponent {
  private readonly destroyRef = inject(DestroyRef);
  private timerId?: ReturnType<typeof setInterval>;

  constructor() {
    this.timerId = setInterval(() => {
      // Timer logic
    }, 250);

    // Automatic cleanup
    this.destroyRef.onDestroy(() => {
      if (this.timerId !== null) {
        clearInterval(this.timerId);
        this.timerId = null;
      }
    });
  }
}

Responsive Design

Viewport Service

Air Tracker includes a ViewportService for responsive behavior. Example from viewport.service.ts:8-42:
import { Injectable, computed, inject } from '@angular/core';
import { BreakpointObserver, Breakpoints } from '@angular/cdk/layout';
import { toSignal } from '@angular/core/rxjs-interop';

@Injectable({ providedIn: 'root' })
export class ViewportService {
  private readonly bp = inject(BreakpointObserver);

  readonly isMobile = computed(() => /* ... */);
  readonly isTablet = computed(() => /* ... */);
  readonly isDesktop = computed(() => /* ... */);
  
  readonly mode = computed<ViewportMode>(() => {
    if (this.isMobile()) return 'mobile';
    if (this.isTablet()) return 'tablet';
    return 'desktop';
  });
}

Using Viewport Service

export class MyComponent {
  protected readonly viewport = inject(ViewportService);
  
  openDetails(): void {
    if (this.viewport.isDesktop()) {
      this.openSidePanel();
    } else {
      this.openBottomSheet();
    }
  }
}
In template:
@if (viewport.isDesktop()) {
  <app-side-panel />
} @else {
  <app-bottom-sheet />
}

Performance Best Practices

OnPush Change Detection

Use OnPush for better performance:
import { Component, ChangeDetectionStrategy } from '@angular/core';

@Component({
  selector: 'app-flight-card',
  changeDetection: ChangeDetectionStrategy.OnPush,
  // ...
})
export class FlightCardComponent {}

TrackBy Functions

Always use track in @for loops:
<!-- ✅ Correct -->
@for (flight of flights(); track flight.icao24) {
  <app-flight-card [flight]="flight" />
}

<!-- ❌ Incorrect - slow re-renders -->
@for (flight of flights(); track $index) {
  <app-flight-card [flight]="flight" />
}

Avoid Memory Leaks

Use DestroyRef or takeUntilDestroyed() for subscriptions:
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';

export class MyComponent {
  private readonly destroyRef = inject(DestroyRef);
  
  ngOnInit(): void {
    this.api.getData()
      .pipe(takeUntilDestroyed(this.destroyRef))
      .subscribe(data => {
        // Handle data
      });
  }
}

Testing Components

See the Testing Guide for detailed component testing patterns. Quick example:
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { MyComponent } from './my-component';

describe('MyComponent', () => {
  let component: MyComponent;
  let fixture: ComponentFixture<MyComponent>;

  beforeEach(async () => {
    await TestBed.configureTestingModule({
      imports: [MyComponent] // Standalone component
    }).compileComponents();

    fixture = TestBed.createComponent(MyComponent);
    component = fixture.componentInstance;
    fixture.detectChanges();
  });

  it('should create', () => {
    expect(component).toBeTruthy();
  });
});

Next Steps

Build docs developers (and LLMs) love