React Integration

Learn how to integrate the Tiro.health Web SDK into your React application with proper hooks, refs, and lifecycle management.

Overview

The Web SDK uses an imperative API that integrates seamlessly with React using:

  • useEffect for component mounting and cleanup
  • useRef for DOM element references
  • useState for managing component state
  • TypeScript for type safety

This guide walks you through a complete React + TypeScript + Vite integration from scratch.


Prerequisites

Before starting, ensure you have:

  • Node.js 18 or later
  • npm or yarn package manager
  • Access to Tiro.health private npm registry (see Installation)
  • Basic knowledge of React hooks and TypeScript

Project Setup

Create a New React Project

Using Vite (recommended for modern React development):

npm create vite@latest my-forms-app -- --template react-ts
cd my-forms-app
npm install

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

Create a .env file in your project root:

# .env
VITE_SDC_ENDPOINT=https://your-sdc-backend.example.com/fhir
VITE_DATA_ENDPOINT=https://your-fhir-server.example.com/fhir
VITE_QUESTIONNAIRE_ID=your-questionnaire-id
VITE_PATIENT_ID=your-patient-id

Important: Add .env to your .gitignore to avoid committing sensitive data.


Basic Integration

Simple FormFiller Component

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

import { useEffect, useRef } from 'react'
import { FormFiller } from '@tiro-health/web-sdk'

export function QuestionnaireForm() {
  const containerRef = useRef<HTMLDivElement>(null)
  const fillerRef = useRef<FormFiller | null>(null)

  useEffect(() => {
    // Ensure container exists
    if (!containerRef.current) return

    // Create FormFiller instance
    const filler = new FormFiller({
      questionnaire: import.meta.env.VITE_QUESTIONNAIRE_ID,
      sdcEndpoint: {
        resourceType: 'Endpoint',
        address: import.meta.env.VITE_SDC_ENDPOINT,
      },
      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 the component
    filler.mount(containerRef.current)
    fillerRef.current = filler

    // Cleanup on unmount
    return () => {
      filler.unmount()
      fillerRef.current = null
    }
  }, []) // Empty dependency array ensures this runs once

  return (
    <div>
      <h1>Patient Questionnaire</h1>
      <div ref={containerRef} />
    </div>
  )
}

Key React Integration Patterns

1. Using useRef for DOM Elements:

const containerRef = useRef<HTMLDivElement>(null)
// ...
<div ref={containerRef} />

2. Using useRef for Component Instances:

const fillerRef = useRef<FormFiller | null>(null)
fillerRef.current = filler

3. Cleanup in useEffect:

useEffect(() => {
  // Mount component
  const filler = new FormFiller(config)
  filler.mount(containerRef.current)

  // Return cleanup function
  return () => {
    filler.unmount()
  }
}, [])

Complete Example

Full-Featured React Component

This example includes all SDK components with visualization features:

import { useEffect, useRef, useState } from 'react'
import {
  FormFiller,
  LaunchContextProvider,
  Narrative,
  ValidationFeedback,
  VisualizationToggle,
} from '@tiro-health/web-sdk'
import './App.css'

export function App() {
  const [isLoading, setIsLoading] = useState(true)
  const [error, setError] = useState<Error | null>(null)

  // DOM element refs
  const contextRef = useRef<HTMLDivElement>(null)
  const formRef = useRef<HTMLDivElement>(null)
  const narrativeRef = useRef<HTMLDivElement>(null)
  const validationRef = useRef<HTMLDivElement>(null)
  const toggleRef = useRef<HTMLDivElement>(null)

  // Component instance refs
  const contextProviderRef = useRef<LaunchContextProvider | null>(null)
  const fillerRef = useRef<FormFiller | null>(null)
  const narrativeCompRef = useRef<Narrative | null>(null)
  const validationCompRef = useRef<ValidationFeedback | null>(null)
  const toggleCompRef = useRef<VisualizationToggle | null>(null)

  useEffect(() => {
    // Ensure all refs are ready
    if (
      !contextRef.current ||
      !formRef.current ||
      !narrativeRef.current ||
      !validationRef.current ||
      !toggleRef.current
    ) {
      return
    }

    try {
      // Create FormFiller
      const filler = new FormFiller({
        questionnaire: import.meta.env.VITE_QUESTIONNAIRE_ID,
        sdcEndpoint: {
          resourceType: 'Endpoint',
          address: import.meta.env.VITE_SDC_ENDPOINT,
        },
        visualize: import.meta.env.DEV, // Only in development
        onSubmit: (response) => {
          console.log('Form submitted:', response)
          alert('Form submitted successfully!')
        },
        onChange: (response) => {
          console.log('Form updated:', response)
        },
        onError: (err) => {
          console.error('Form error:', err)
          setError(err)
        },
      })

      // Create LaunchContextProvider
      const contextProvider = new LaunchContextProvider({
        dataEndpoint: {
          resourceType: 'Endpoint',
          address: import.meta.env.VITE_DATA_ENDPOINT,
        },
        filler: filler,
        patientId: import.meta.env.VITE_PATIENT_ID,
      })

      // Create Narrative
      const narrative = new Narrative({
        filler: filler,
        visualize: import.meta.env.DEV,
      })

      // Create ValidationFeedback
      const validation = new ValidationFeedback({
        filler: filler,
        visualize: import.meta.env.DEV,
      })

      // Create VisualizationToggle
      const toggle = new VisualizationToggle()

      // Mount all components
      contextProvider.mount(contextRef.current)
      filler.mount(formRef.current)
      narrative.mount(narrativeRef.current)
      validation.mount(validationRef.current)
      toggle.mount(toggleRef.current)

      // Store refs for cleanup
      contextProviderRef.current = contextProvider
      fillerRef.current = filler
      narrativeCompRef.current = narrative
      validationCompRef.current = validation
      toggleCompRef.current = toggle

      setIsLoading(false)
    } catch (err) {
      console.error('Failed to initialize components:', err)
      setError(err as Error)
      setIsLoading(false)
    }

    // Cleanup function
    return () => {
      contextProviderRef.current?.unmount()
      fillerRef.current?.unmount()
      narrativeCompRef.current?.unmount()
      validationCompRef.current?.unmount()
      toggleCompRef.current?.unmount()

      contextProviderRef.current = null
      fillerRef.current = null
      narrativeCompRef.current = null
      validationCompRef.current = null
      toggleCompRef.current = null
    }
  }, []) // Run once on mount

  if (error) {
    return (
      <div className="error">
        <h1>Error</h1>
        <p>{error.message}</p>
        <button onClick={() => window.location.reload()}>Retry</button>
      </div>
    )
  }

  if (isLoading) {
    return (
      <div className="loading">
        <h1>Loading...</h1>
      </div>
    )
  }

  return (
    <div className="app">
      <header>
        <h1>Patient Forms</h1>
        <div ref={toggleRef} />
      </header>

      <div className="layout">
        <aside className="sidebar">
          <h2>Context</h2>
          <div ref={contextRef} />

          <h2>Validation</h2>
          <div ref={validationRef} />
        </aside>

        <main className="main-content">
          <h2>Questionnaire</h2>
          <div ref={formRef} />

          <h2>Clinical Narrative</h2>
          <div ref={narrativeRef} />
        </main>
      </div>
    </div>
  )
}

Styling Example

Basic CSS for the layout:

/* App.css */
.app {
  max-width: 1200px;
  margin: 0 auto;
  padding: 2rem;
}

header {
  display: flex;
  justify-content: space-between;
  align-items: center;
  margin-bottom: 2rem;
}

.layout {
  display: grid;
  grid-template-columns: 300px 1fr;
  gap: 2rem;
}

.sidebar {
  display: flex;
  flex-direction: column;
  gap: 1.5rem;
}

.main-content {
  display: flex;
  flex-direction: column;
  gap: 1.5rem;
}

.error {
  text-align: center;
  padding: 2rem;
  color: #d32f2f;
}

.loading {
  text-align: center;
  padding: 2rem;
}

/* Responsive layout */
@media (max-width: 768px) {
  .layout {
    grid-template-columns: 1fr;
  }
}

TypeScript Types

Component Configuration Types

import type {
  FormFiller,
  LaunchContextProvider,
  Narrative,
  ValidationFeedback,
  QuestionnaireResponse,
} from '@tiro-health/web-sdk'

// FormFiller configuration
interface FormFillerConfig {
  questionnaire: string
  sdcEndpoint: {
    resourceType: 'Endpoint'
    address: string
  }
  initialResponse?: QuestionnaireResponse
  onSubmit?: (response: QuestionnaireResponse) => void
  onChange?: (response: QuestionnaireResponse) => void
  onError?: (error: Error) => void
  visualize?: boolean
}

// LaunchContextProvider configuration
interface LaunchContextConfig {
  dataEndpoint: {
    resourceType: 'Endpoint'
    address: string
  }
  filler: FormFiller
  patientId?: string
}

Custom Hook Example

Create a reusable hook for FormFiller:

import { useEffect, useRef } from 'react'
import { FormFiller } from '@tiro-health/web-sdk'

interface UseFormFillerOptions {
  questionnaire: string
  sdcEndpoint: string
  onSubmit?: (response: any) => void
  onChange?: (response: any) => void
  onError?: (error: Error) => void
}

export function useFormFiller(options: UseFormFillerOptions) {
  const containerRef = useRef<HTMLDivElement>(null)
  const fillerRef = useRef<FormFiller | null>(null)

  useEffect(() => {
    if (!containerRef.current) return

    const filler = new FormFiller({
      questionnaire: options.questionnaire,
      sdcEndpoint: {
        resourceType: 'Endpoint',
        address: options.sdcEndpoint,
      },
      onSubmit: options.onSubmit,
      onChange: options.onChange,
      onError: options.onError,
      visualize: import.meta.env.DEV,
    })

    filler.mount(containerRef.current)
    fillerRef.current = filler

    return () => {
      filler.unmount()
      fillerRef.current = null
    }
  }, [options.questionnaire, options.sdcEndpoint])

  return {
    containerRef,
    filler: fillerRef.current,
  }
}

// Usage in component:
function MyForm() {
  const { containerRef } = useFormFiller({
    questionnaire: 'phq-9',
    sdcEndpoint: 'https://api.example.com/fhir',
    onSubmit: (response) => console.log('Submitted:', response),
  })

  return <div ref={containerRef} />
}

Best Practices

1. Environment Variables

Always use environment variables for configuration:

// ✅ Good
const endpoint = import.meta.env.VITE_SDC_ENDPOINT

// ❌ Bad - hardcoded
const endpoint = 'https://api.example.com/fhir'

2. Error Boundaries

Wrap your form components in error boundaries:

import { Component, ErrorInfo, ReactNode } from 'react'

interface Props {
  children: ReactNode
}

interface State {
  hasError: boolean
  error: Error | null
}

export class FormErrorBoundary extends Component<Props, State> {
  constructor(props: Props) {
    super(props)
    this.state = { hasError: false, error: null }
  }

  static getDerivedStateFromError(error: Error): State {
    return { hasError: true, error }
  }

  componentDidCatch(error: Error, errorInfo: ErrorInfo) {
    console.error('Form error boundary caught:', error, errorInfo)
  }

  render() {
    if (this.state.hasError) {
      return (
        <div className="error-boundary">
          <h2>Something went wrong</h2>
          <p>{this.state.error?.message}</p>
          <button onClick={() => this.setState({ hasError: false, error: null })}>
            Try again
          </button>
        </div>
      )
    }

    return this.props.children
  }
}

// Usage:
<FormErrorBoundary>
  <QuestionnaireForm />
</FormErrorBoundary>

3. Loading States

Always handle loading states properly:

const [isLoading, setIsLoading] = useState(true)

useEffect(() => {
  // ... mount components
  setIsLoading(false)
}, [])

if (isLoading) return <LoadingSpinner />

4. Cleanup

Always unmount components in useEffect cleanup:

useEffect(() => {
  const filler = new FormFiller(config)
  filler.mount(containerRef.current)

  return () => {
    filler.unmount() // Critical for preventing memory leaks
  }
}, [])

Common Pitfalls

1. Forgetting Cleanup

Problem:

// ❌ Missing cleanup - memory leak!
useEffect(() => {
  const filler = new FormFiller(config)
  filler.mount(containerRef.current)
}, [])

Solution:

// ✅ Proper cleanup
useEffect(() => {
  const filler = new FormFiller(config)
  filler.mount(containerRef.current)

  return () => {
    filler.unmount()
  }
}, [])

2. Mounting Before DOM is Ready

Problem:

// ❌ containerRef.current might be null
useEffect(() => {
  const filler = new FormFiller(config)
  filler.mount(containerRef.current) // Error if null!
}, [])

Solution:

// ✅ Check if ref exists first
useEffect(() => {
  if (!containerRef.current) return

  const filler = new FormFiller(config)
  filler.mount(containerRef.current)

  return () => {
    filler.unmount()
  }
}, [])

3. Dependency Array Issues

Problem:

// ❌ Re-mounts on every render
useEffect(() => {
  // ... mount component
}, [config]) // config is a new object every render!

Solution:

// ✅ Use empty array or specific values
useEffect(() => {
  // ... mount component
}, []) // Mount once

// Or with specific dependencies:
useEffect(() => {
  // ... mount component
}, [questionnaireId, sdcEndpoint]) // Only re-mount when these change

4. Not Handling Errors

Problem:

// ❌ No error handling
const filler = new FormFiller({
  questionnaire: 'my-form',
  sdcEndpoint: { /* ... */ }
})

Solution:

// ✅ Always handle errors
const filler = new FormFiller({
  questionnaire: 'my-form',
  sdcEndpoint: { /* ... */ },
  onError: (error) => {
    console.error('Form error:', error)
    setError(error)
    // Show user-friendly error message
  }
})

Next Steps

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

Was this page helpful?