Angular Integration

Learn how to integrate the Tiro.health Web SDK into your Angular application using lifecycle hooks, ViewChild, and proper component architecture.

Overview

The Web SDK's imperative API integrates naturally with Angular using:

  • Lifecycle hooks (ngAfterViewInit, ngOnDestroy) for mounting and cleanup
  • ViewChild / ElementRef for DOM element references
  • Standalone components (Angular 14+) or NgModule
  • TypeScript for full type safety
  • Environment variables for configuration

This guide covers Angular 15+ with standalone components and TypeScript.


Prerequisites

Before starting, ensure you have:

  • Node.js 18 or later
  • Angular CLI 15 or later
  • Access to Tiro.health private npm registry (see Installation)
  • Basic knowledge of Angular components and lifecycle hooks

Project Setup

Create a New Angular Project

npm install -g @angular/cli
ng new my-forms-app
cd my-forms-app

When prompted:

  • Routing: Yes
  • Stylesheet format: CSS (or your preference)
  • Standalone components: Yes (recommended)

Configure NPM Registry

Add registry authentication to .npmrc in your project root:

@tiro-health:registry=https://europe-npm.pkg.dev/tiroapp-4cb17/npm-ext/
//europe-npm.pkg.dev/tiroapp-4cb17/npm-ext/:always-auth=true

Install the Web SDK

npm install @tiro-health/web-sdk

Configure Environment Variables

Angular uses environment.ts files for configuration. Update src/environments/environment.ts:

export const environment = {
  production: false,
  sdcEndpoint: 'https://your-sdc-backend.example.com/fhir',
  dataEndpoint: 'https://your-fhir-server.example.com/fhir',
  questionnaireId: 'your-questionnaire-id',
  patientId: 'your-patient-id',
}

And src/environments/environment.prod.ts for production:

export const environment = {
  production: true,
  sdcEndpoint: 'https://your-production-sdc-backend.example.com/fhir',
  dataEndpoint: 'https://your-production-fhir-server.example.com/fhir',
  questionnaireId: 'your-questionnaire-id',
  patientId: 'your-patient-id',
}

Basic Integration

Simple FormFiller Component

Here's a minimal Angular component that integrates the FormFiller:

Component TypeScript:

import { Component, AfterViewInit, OnDestroy, ViewChild, ElementRef } from '@angular/core'
import { FormFiller } from '@tiro-health/web-sdk'
import { environment } from '../environments/environment'

@Component({
  selector: 'app-questionnaire-form',
  standalone: true,
  templateUrl: './questionnaire-form.component.html',
  styleUrls: ['./questionnaire-form.component.css']
})
export class QuestionnaireFormComponent implements AfterViewInit, OnDestroy {
  @ViewChild('formContainer') formContainer!: ElementRef<HTMLDivElement>

  private filler?: FormFiller

  ngAfterViewInit(): void {
    // Create FormFiller instance
    this.filler = new FormFiller({
      questionnaire: environment.questionnaireId,
      sdcEndpoint: {
        resourceType: 'Endpoint',
        address: environment.sdcEndpoint,
      },
      onSubmit: (response) => {
        console.log('Form submitted:', response)
        alert('Form submitted successfully!')
      },
      onChange: (response) => {
        console.log('Form changed:', response)
      },
      onError: (error) => {
        console.error('Form error:', error)
        alert(\`Error: \${error.message}\`)
      },
    })

    // Mount to the DOM element
    this.filler.mount(this.formContainer.nativeElement)
  }

  ngOnDestroy(): void {
    // Clean up when component is destroyed
    this.filler?.unmount()
  }
}

Template HTML:

<div class="questionnaire-container">
  <h1>Patient Questionnaire</h1>
  <div #formContainer></div>
</div>

Key Angular Integration Patterns

1. Using ViewChild for DOM References:

@ViewChild('formContainer') formContainer!: ElementRef<HTMLDivElement>

2. Mounting in ngAfterViewInit:

ngAfterViewInit(): void {
  this.filler = new FormFiller(config)
  this.filler.mount(this.formContainer.nativeElement)
}

3. Cleanup in ngOnDestroy:

ngOnDestroy(): void {
  this.filler?.unmount()
}

Complete Example

Full-Featured Angular Component

This example includes all SDK components with visualization features:

import {
  Component,
  AfterViewInit,
  OnDestroy,
  ViewChild,
  ElementRef,
} from '@angular/core'
import { CommonModule } from '@angular/common'
import {
  FormFiller,
  LaunchContextProvider,
  Narrative,
  ValidationFeedback,
  VisualizationToggle,
} from '@tiro-health/web-sdk'
import { environment } from '../environments/environment'

@Component({
  selector: 'app-patient-form',
  standalone: true,
  imports: [CommonModule],
  templateUrl: './patient-form.component.html',
  styleUrls: ['./patient-form.component.css']
})
export class PatientFormComponent implements AfterViewInit, OnDestroy {
  @ViewChild('contextContainer') contextContainer!: ElementRef<HTMLDivElement>
  @ViewChild('formContainer') formContainer!: ElementRef<HTMLDivElement>
  @ViewChild('narrativeContainer') narrativeContainer!: ElementRef<HTMLDivElement>
  @ViewChild('validationContainer') validationContainer!: ElementRef<HTMLDivElement>
  @ViewChild('toggleContainer') toggleContainer!: ElementRef<HTMLDivElement>

  private contextProvider?: LaunchContextProvider
  private filler?: FormFiller
  private narrative?: Narrative
  private validation?: ValidationFeedback
  private toggle?: VisualizationToggle

  isLoading = true
  error: Error | null = null

  ngAfterViewInit(): void {
    try {
      // Create FormFiller
      this.filler = new FormFiller({
        questionnaire: environment.questionnaireId,
        sdcEndpoint: {
          resourceType: 'Endpoint',
          address: environment.sdcEndpoint,
        },
        visualize: !environment.production,
        onSubmit: (response) => {
          console.log('Form submitted:', response)
          alert('Form submitted successfully!')
        },
        onChange: (response) => {
          console.log('Form updated:', response)
        },
        onError: (error) => {
          console.error('Form error:', error)
          this.error = error
        },
      })

      // Create LaunchContextProvider
      this.contextProvider = new LaunchContextProvider({
        dataEndpoint: {
          resourceType: 'Endpoint',
          address: environment.dataEndpoint,
        },
        filler: this.filler,
        patientId: environment.patientId,
      })

      // Create Narrative
      this.narrative = new Narrative({
        filler: this.filler,
        visualize: !environment.production,
      })

      // Create ValidationFeedback
      this.validation = new ValidationFeedback({
        filler: this.filler,
        visualize: !environment.production,
      })

      // Create VisualizationToggle
      this.toggle = new VisualizationToggle()

      // Mount all components
      this.contextProvider.mount(this.contextContainer.nativeElement)
      this.filler.mount(this.formContainer.nativeElement)
      this.narrative.mount(this.narrativeContainer.nativeElement)
      this.validation.mount(this.validationContainer.nativeElement)
      this.toggle.mount(this.toggleContainer.nativeElement)

      this.isLoading = false
    } catch (error) {
      console.error('Failed to initialize components:', error)
      this.error = error as Error
      this.isLoading = false
    }
  }

  ngOnDestroy(): void {
    // Clean up all components
    this.contextProvider?.unmount()
    this.filler?.unmount()
    this.narrative?.unmount()
    this.validation?.unmount()
    this.toggle?.unmount()
  }

  retry(): void {
    this.error = null
    this.isLoading = true
    this.ngAfterViewInit()
  }
}

Using in App Module

If using NgModule-based architecture, add to your app.module.ts:

import { NgModule } from '@angular/core'
import { BrowserModule } from '@angular/platform-browser'
import { AppComponent } from './app.component'
import { PatientFormComponent } from './patient-form/patient-form.component'

@NgModule({
  declarations: [AppComponent],
  imports: [
    BrowserModule,
    PatientFormComponent, // Standalone component
  ],
  bootstrap: [AppComponent]
})
export class AppModule {}

Using in Routes

Add to your app.routes.ts:

import { Routes } from '@angular/router'
import { PatientFormComponent } from './patient-form/patient-form.component'

export const routes: Routes = [
  { path: '', redirectTo: '/form', pathMatch: 'full' },
  { path: 'form', component: PatientFormComponent },
]

TypeScript Configuration

Update tsconfig.json

Ensure your tsconfig.json includes proper configuration:

{
  "compilerOptions": {
    "target": "ES2022",
    "module": "ES2022",
    "lib": ["ES2022", "DOM"],
    "strict": true,
    "esModuleInterop": true,
    "skipLibCheck": true,
    "moduleResolution": "node"
  }
}

Type Definitions

Create custom type definitions if needed (src/types/web-sdk.d.ts):

declare module '@tiro-health/web-sdk' {
  export interface QuestionnaireResponse {
    resourceType: 'QuestionnaireResponse'
    status: string
    authored?: string
    item?: any[]
  }

  export interface EndpointConfig {
    resourceType: 'Endpoint'
    address: string
  }

  export interface FormFillerConfig {
    questionnaire: string
    sdcEndpoint: EndpointConfig
    initialResponse?: QuestionnaireResponse
    onSubmit?: (response: QuestionnaireResponse) => void
    onChange?: (response: QuestionnaireResponse) => void
    onError?: (error: Error) => void
    visualize?: boolean
  }

  export class FormFiller {
    constructor(config: FormFillerConfig)
    mount(element: HTMLElement): void
    unmount(): void
  }

  export class LaunchContextProvider {
    constructor(config: {
      dataEndpoint: EndpointConfig
      filler: FormFiller
      patientId?: string
    })
    mount(element: HTMLElement): void
    unmount(): void
  }

  export class Narrative {
    constructor(config: { filler: FormFiller; visualize?: boolean })
    mount(element: HTMLElement): void
    unmount(): void
  }

  export class ValidationFeedback {
    constructor(config: { filler: FormFiller; visualize?: boolean })
    mount(element: HTMLElement): void
    unmount(): void
  }

  export class VisualizationToggle {
    constructor()
    mount(element: HTMLElement): void
    unmount(): void
  }
}

Best Practices

1. Use Angular Services for Configuration

Create a configuration service:

import { Injectable } from '@angular/core'
import { environment } from '../environments/environment'

@Injectable({
  providedIn: 'root'
})
export class FormConfigService {
  getSDCEndpoint() {
    return {
      resourceType: 'Endpoint' as const,
      address: environment.sdcEndpoint,
    }
  }

  getDataEndpoint() {
    return {
      resourceType: 'Endpoint' as const,
      address: environment.dataEndpoint,
    }
  }

  getQuestionnaireId(): string {
    return environment.questionnaireId
  }

  getPatientId(): string {
    return environment.patientId
  }

  shouldVisualize(): boolean {
    return !environment.production
  }
}

// Usage in component:
constructor(private config: FormConfigService) {}

ngAfterViewInit(): void {
  this.filler = new FormFiller({
    questionnaire: this.config.getQuestionnaireId(),
    sdcEndpoint: this.config.getSDCEndpoint(),
    visualize: this.config.shouldVisualize(),
  })
}

2. Error Handling Service

Create a dedicated error handling service:

import { Injectable } from '@angular/core'
import { Subject } from 'rxjs'

@Injectable({
  providedIn: 'root'
})
export class FormErrorService {
  private errorSubject = new Subject<Error>()
  public errors$ = this.errorSubject.asObservable()

  handleError(error: Error): void {
    console.error('Form error:', error)
    this.errorSubject.next(error)
    // Could also send to logging service, show toast, etc.
  }
}

// Usage in component:
constructor(private errorService: FormErrorService) {}

ngAfterViewInit(): void {
  this.filler = new FormFiller({
    // ...
    onError: (error) => this.errorService.handleError(error),
  })
}

3. Loading State Management

Use Angular's built-in change detection:

export class PatientFormComponent {
  isLoading = true

  ngAfterViewInit(): void {
    // Component initialization...

    // Use setTimeout to ensure change detection runs
    setTimeout(() => {
      this.isLoading = false
    }, 0)
  }
}

4. Cleanup

Always implement OnDestroy and clean up:

export class MyComponent implements OnDestroy {
  private filler?: FormFiller

  ngOnDestroy(): void {
    this.filler?.unmount() // Prevents memory leaks
  }
}

Common Pitfalls

1. Mounting Before View is Ready

Problem:

// ❌ ViewChild not available yet
ngOnInit(): void {
  this.filler = new FormFiller(config)
  this.filler.mount(this.formContainer.nativeElement) // Error!
}

Solution:

// ✅ Use ngAfterViewInit
ngAfterViewInit(): void {
  this.filler = new FormFiller(config)
  this.filler.mount(this.formContainer.nativeElement)
}

2. Forgetting ngOnDestroy

Problem:

// ❌ No cleanup - memory leak
export class MyComponent implements AfterViewInit {
  ngAfterViewInit(): void {
    this.filler = new FormFiller(config)
    this.filler.mount(this.formContainer.nativeElement)
  }
}

Solution:

// ✅ Implement OnDestroy
export class MyComponent implements AfterViewInit, OnDestroy {
  ngAfterViewInit(): void {
    this.filler = new FormFiller(config)
    this.filler.mount(this.formContainer.nativeElement)
  }

  ngOnDestroy(): void {
    this.filler?.unmount()
  }
}

3. Missing ViewChild Type

Problem:

// ❌ No type - harder to debug
@ViewChild('formContainer') formContainer: any

Solution:

// ✅ Properly typed
@ViewChild('formContainer') formContainer!: ElementRef<HTMLDivElement>

4. Not Handling Change Detection

Problem:

// ❌ State change might not trigger view update
ngAfterViewInit(): void {
  this.isLoading = false // Might not update view
}

Solution:

// ✅ Use setTimeout for async state changes
ngAfterViewInit(): void {
  setTimeout(() => {
    this.isLoading = false
  }, 0)
}

// Or inject ChangeDetectorRef and call detectChanges()
constructor(private cdr: ChangeDetectorRef) {}

ngAfterViewInit(): void {
  this.isLoading = false
  this.cdr.detectChanges()
}

5. Accessing nativeElement Too Early

Problem:

// ❌ ViewChild might not be initialized
@ViewChild('container') container!: ElementRef

constructor() {
  console.log(this.container.nativeElement) // Undefined!
}

Solution:

// ✅ Access in ngAfterViewInit
ngAfterViewInit(): void {
  console.log(this.container.nativeElement) // Available now
}

Next Steps

For questions or support, please contact the Tiro.health team.

Was this page helpful?