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
- Explore the FormFiller API Reference for advanced configuration
- Learn about LaunchContextProvider for patient context
- Check out other framework integrations
- View live examples in the tutorial repository
For questions or support, please contact the Tiro.health team.