Skip to main content

Questionnaire Components Guide

This guide covers the shared components that help building frontends to manage FHIR based questionnaires. They help with:

  • Loading questionnaires from a questionaire server or the the Welshare API
  • Managing response state (supports flat or nested structures)
  • Rendering different question types
  • Validation and error handling
  • Progress tracking
  • Submitting response data to Welshare profiles

Installation

The component library is published on NPM: https://www.npmjs.com/package/@welshare/questionnaire

A storybook that demonstrates the use of some basic components: https://welshare-questionnaire-storybook.vercel.app/

Questionnaire Components Storybook

Components

QuestionnaireProvider

A context provider that manages questionnaire state, responses, and validation.

Props

  • questionnaire: Questionnaire - The FHIR Questionnaire object to render (clients are responsible for loading/fetching the definitions)
  • questionnaireId?: string - Optional questionnaire ID to use in the response. If not provided, will use questionnaire.id. If neither exists, an error will be thrown.
  • useNestedStructure?: boolean - Whether to use nested (hierarchical) or flat response structure (default: true)
  • children: ReactNode - Child components

Example

import { QuestionnaireProvider, Questionnaire } from "@welshare/questionnaire";

function App() {
const [questionnaire, setQuestionnaire] = useState<Questionnaire | null>(
null
);

useEffect(() => {
// Client is responsible for fetching the questionnaire
async function loadQuestionnaire() {
const response = await fetch(
"https://api.welshare.app/api/questionnaire/your-id"
);
const data = await response.json();
setQuestionnaire(data);
}
loadQuestionnaire();
}, []);

if (!questionnaire) return <div>Loading...</div>;

return (
<QuestionnaireProvider
questionnaire={questionnaire}
questionnaireId="optional-override-id" // Optional: defaults to questionnaire.id
useNestedStructure={true}
>
<YourQuestionnaireComponents />
</QuestionnaireProvider>
);
}

useQuestionnaire Hook

Access questionnaire state and methods from any component within QuestionnaireProvider.

Available Methods

const {
questionnaire, // The questionnaire object (from provider props)
response, // Current questionnaire response
updateAnswer, // Update a single answer
updateMultipleAnswers, // Update multiple answers (for repeating questions)
getAnswer, // Get a single answer by linkId
getAnswers, // Get all answers for a linkId (for repeating questions)
isPageValid, // Check if all required questions on a page are answered
getRequiredQuestions, // Get required questions from a page
getUnansweredRequiredQuestions, // Get unanswered required questions
markValidationErrors, // Mark validation errors for display
clearValidationErrors, // Clear all validation errors
hasValidationError, // Check if a specific question has a validation error
debugMode, // Boolean indicating if debug mode is enabled
toggleDebugMode, // Function to toggle debug mode on/off
} = useQuestionnaire();

QuestionRenderer

Renders individual questionnaire items based on their type.

Supported Question Types

  • choice - Single select (radio) or multiple select (checkbox)
  • integer - Number input (with optional slider control)
  • decimal - Decimal number input
  • string / text - Text input
  • boolean - Yes/No radio buttons

Special Features

  • Hidden Fields: Questions with questionnaire-hidden extension are automatically hidden
  • Slider Controls: Integer questions can use slider controls via extension
  • Exclusive Options: Multi-select questions can have exclusive options (e.g., "None of the above")
  • Max Answers: Limit the number of selections in multi-select questions
  • Validation Errors: Automatically displays validation state

Props

interface QuestionRendererProps {
item: QuestionnaireItem;
className?: string; // Custom class for container
inputClassName?: string; // Custom class for input elements
choiceClassName?: string; // Custom class for choice options
renderRadioInput?: (props: RadioInputProps) => ReactNode; // Custom radio button renderer
renderCheckboxInput?: (props: CheckboxInputProps) => ReactNode; // Custom checkbox renderer
}

Custom Renderer Props:

Both renderRadioInput and renderCheckboxInput receive the following props:

  • linkId: string - The question item's linkId
  • valueCoding?: { system?: string; code?: string; display?: string } - The option's value coding
  • valueInteger?: number - The option's integer value (if applicable)
  • checked: boolean - Whether this option is currently selected
  • disabled?: boolean - Whether this option is disabled
  • onChange: () => void - Callback when the option is selected/toggled
  • label: string - The display text for this option
  • index: number - The index of this option in the list

Example

import { QuestionRenderer, useQuestionnaire } from "@welshare/questionnaire";

function QuestionnairePage() {
const { questionnaire } = useQuestionnaire();

if (!questionnaire?.item) return null;

return (
<div>
{questionnaire.item.map((page) => (
<div key={page.linkId}>
<h2>{page.text}</h2>
{page.item?.map((question) => (
<QuestionRenderer key={question.linkId} item={question} />
))}
</div>
))}
</div>
);
}

Custom Input Renderers

You can provide custom renderers for radio buttons and checkboxes to integrate with your design system:

import {
QuestionRenderer,
type RadioInputProps,
type CheckboxInputProps,
} from "@welshare/questionnaire";

function QuestionnairePage() {
return (
<QuestionRenderer
item={questionItem}
renderRadioInput={(props: RadioInputProps) => (
<label className="custom-radio">
<input
type="radio"
checked={props.checked}
onChange={props.onChange}
/>
{props.label}
</label>
)}
renderCheckboxInput={(props: CheckboxInputProps) => (
<label className="custom-checkbox">
<input
type="checkbox"
checked={props.checked}
onChange={props.onChange}
disabled={props.disabled}
/>
{props.label}
</label>
)}
/>
);
}

Custom question components

Utility Functions

Progress Calculation

import { calculateProgress, getVisiblePages } from "@welshare/questionnaire";

const pages = getVisiblePages(questionnaire);
const progress = calculateProgress(currentPageIndex, pages.length);
// Returns percentage: 0-100

Question Utilities

import {
getAllQuestionsFromPage,
hasAnswerValue,
isQuestionHidden,
getExclusiveOptionCode,
} from "@welshare/questionnaire";

// Get all questions from a page (flattens nested groups)
const questions = getAllQuestionsFromPage(pageItem);

// Check if an answer has a value
const hasValue = hasAnswerValue(answer);

// Check if a question should be hidden
const isHidden = isQuestionHidden(questionItem);

// Get exclusive option code (if any)
const exclusiveCode = getExclusiveOptionCode(questionItem);

Styling

The questionnaire components use a modern CSS architecture with design tokens and scoped class names.

Import CSS Files

Import both the design tokens and styles:

// Import design tokens (CSS custom properties)
import "@welshare/questionnaire/tokens.css";

// Import component styles
import "@welshare/questionnaire/styles.css";

Design Tokens (CSS Custom Properties)

All styles are built on CSS custom properties that you can easily override:

/* Override design tokens in your own CSS */
:root {
/* Colors */
--wq-color-primary: #your-brand-color;
--wq-color-border: #custom-border-color;

/* Spacing */
--wq-space-lg: 1.5rem;

/* Typography */
--wq-font-size-lg: 1.25rem;
--wq-font-weight-medium: 600;

/* Borders & Radius */
--wq-radius-md: 0.75rem;
--wq-border-width: 1px;
}

Available Design Tokens

Spacing: --wq-space-{xs,sm,md,lg,xl,2xl}

Font Sizes: --wq-font-size-{sm,base,lg,xl}

Font Weights: --wq-font-weight-{normal,medium,semibold,bold}

Border Radius: --wq-radius-{sm,md,lg,full}

Colors:

  • Neutral: --wq-color-gray-{50...900}
  • Primary: --wq-color-primary-{50,100,200,500,600,700}
  • Error: --wq-color-error-{50,500,600}
  • Semantic: --wq-color-{background,surface,border,text-primary,selected}

Transitions: --wq-transition-{fast,base,slow}

Shadows: --wq-shadow-{sm,md,focus}

Scoped CSS Classes

All classes use the .wq- prefix to avoid conflicts:

  • .wq-question-container - Main container for each question
  • .wq-question-container.wq-has-error - Error state
  • .wq-question-text - Question text
  • .wq-required-indicator - Required asterisk
  • .wq-question-choice - Choice question container
  • .wq-choice-option - Individual choice option
  • .wq-choice-option.wq-selected - Selected choice
  • .wq-choice-option.wq-disabled - Disabled choice
  • .wq-question-input - Text/number inputs
  • .wq-question-slider - Slider container
  • .wq-slider-input - Slider input element
  • .wq-progress-bar - Progress bar container
  • .wq-progress-fill - Progress bar fill

Custom Styling

You can add custom styles in three ways:

1. Override Design Tokens (Recommended):

:root {
--wq-color-primary: #6366f1;
--wq-radius-md: 0.5rem;
}

2. Override Scoped Classes:

.wq-choice-option {
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
}

3. Pass Additional Classes via Props:

<QuestionRenderer
item={question}
className="my-custom-class"
inputClassName="my-input-class"
choiceClassName="my-choice-class"
/>

Complete Example

Here's a complete example of a questionnaire page with navigation:

import { useState, useEffect } from "react";
import {
QuestionnaireProvider,
useQuestionnaire,
QuestionRenderer,
getVisiblePages,
calculateProgress,
type Questionnaire,
} from "@welshare/questionnaire";
import "@welshare/questionnaire/tokens.css";
import "@welshare/questionnaire/styles.css";

function QuestionnairePage() {
const {
questionnaire,
response,
isPageValid,
markValidationErrors,
clearValidationErrors,
debugMode,
toggleDebugMode,
} = useQuestionnaire();

const [currentPageIndex, setCurrentPageIndex] = useState(0);

const pages = getVisiblePages(questionnaire);
const currentPage = pages[currentPageIndex];
const progress = calculateProgress(currentPageIndex, pages.length);
const isCurrentPageValid = currentPage?.item
? isPageValid(currentPage.item)
: true;

const handleNext = () => {
if (isCurrentPageValid) {
clearValidationErrors();
setCurrentPageIndex((prev) => prev + 1);
} else {
markValidationErrors(currentPage.item || []);
}
};

const handlePrevious = () => {
if (currentPageIndex > 0) {
setCurrentPageIndex((prev) => prev - 1);
}
};

const handleSubmit = async () => {
if (isCurrentPageValid) {
// Submit the response to your API
// Note: For Welshare submission, use @welshare/react package
console.log("Submitting response:", response);
// await submitToYourAPI(response);
} else {
markValidationErrors(currentPage.item || []);
}
};

return (
<div className="questionnaire-container">
<h1>{questionnaire.title}</h1>

{/* Debug Mode Toggle */}
<button onClick={toggleDebugMode}>
{debugMode ? "Disable" : "Enable"} Debug Mode
</button>

{/* Progress Bar */}
<div className="wq-progress-bar">
<div className="wq-progress-fill" style={{ width: `${progress}%` }} />
</div>
<p>
Page {currentPageIndex + 1} of {pages.length}
</p>

{/* Current Page Questions */}
<div className="questionnaire-page">
<h2>{currentPage.text}</h2>
{currentPage.item?.map((question) => (
<QuestionRenderer key={question.linkId} item={question} />
))}
</div>

{/* Navigation Buttons */}
<div className="navigation-buttons">
<button onClick={handlePrevious} disabled={currentPageIndex === 0}>
Previous
</button>

{currentPageIndex < pages.length - 1 ? (
<button onClick={handleNext}>Next</button>
) : (
<button onClick={handleSubmit}>Submit</button>
)}
</div>
</div>
);
}

function App() {
const [questionnaire, setQuestionnaire] = useState<Questionnaire | null>(
null
);
const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState<string | null>(null);

useEffect(() => {
async function loadQuestionnaire() {
try {
const response = await fetch(
`${process.env.API_BASE_URL}/api/questionnaire/${process.env.QUESTIONNAIRE_ID}`
);
if (!response.ok) throw new Error("Failed to load questionnaire");
const data = await response.json();
setQuestionnaire(data);
} catch (err) {
setError(err instanceof Error ? err.message : "Unknown error");
} finally {
setIsLoading(false);
}
}
loadQuestionnaire();
}, []);

if (isLoading) return <div>Loading questionnaire...</div>;
if (error) return <div>Error: {error}</div>;
if (!questionnaire) return <div>No questionnaire found</div>;

return (
<QuestionnaireProvider questionnaire={questionnaire}>
<QuestionnairePage />
</QuestionnaireProvider>
);
}

Note: For submitting questionnaire responses to Welshare, you'll need to use the @welshare/react package which provides the useWelshare hook and Schemas export. See the Welshare React SDK documentation for details.

FHIR Extensions Support

Hidden Questions

{
extension: [
{
url: "http://hl7.org/fhir/StructureDefinition/questionnaire-hidden",
valueBoolean: true,
},
];
}

Slider Control

{
extension: [
{
url: "http://codes.welshare.app/StructureDefinition/questionnaire-slider-control",
extension: [
{ url: "minValue", valueInteger: 0 },
{ url: "maxValue", valueInteger: 100 },
{ url: "step", valueInteger: 1 },
{ url: "unit", valueString: "minutes" },
],
},
];
}

Note: The slider control extension URL in the code uses http://codes.welshare.app/StructureDefinition/questionnaire-slider-control, but the extension structure should match the format above.

Exclusive Option (for multi-select)

{
extension: [
{
url: "http://codes.welshare.app/StructureDefinition/questionnaire-exclusive-option",
valueString: "none-of-the-above-code",
},
];
}