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

  • coding - Single select (radio) or multiple select (checkbox) with coded answers
  • integer - Number input (with optional slider control)
  • decimal - Decimal number input
  • quantity - Numeric input with unit selection
  • 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
  • Custom Text Answers: Choice questions can allow free-text "Other" input via answerConstraint
  • Quantity with Units: Numeric input with unit selection for measurements
  • 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
choiceLayout?: "stacked" | "inline-wrap"; // Choice layout mode (default: "stacked")
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"
/>

Choice Layout

Choice questions support two layout modes via the choiceLayout prop. This applies to coding, choice, and boolean question types.

ModeBehavior
"stacked" (default)Vertical column, one option per row
"inline-wrap"Horizontal chips that wrap to the next line
<QuestionRenderer item={item} choiceLayout="inline-wrap" />

The container emits a layout class (.wq-choice-layout-stacked or .wq-choice-layout-inline-wrap) alongside .wq-question-choice and any choiceClassName you pass.

Chip Tokens

When using inline-wrap, these tokens control chip appearance:

:root {
--wq-choice-chip-radius: 9999px; /* pill shape */
--wq-choice-chip-padding-x: 1rem;
--wq-choice-chip-padding-y: 0.5rem;
--wq-choice-chip-gap: 0.5rem;
}

Example: Dark Chip Theme

Combine the layout prop with token overrides and a scoped CSS file for a dark chip UI:

<QuestionRenderer
item={item}
choiceLayout="inline-wrap"
choiceClassName="questionnaire-choice"
/>
/* Theme tokens */
:root {
--wq-color-surface: hsl(246 65% 10%);
--wq-color-border: hsl(234 50% 20%);
--wq-color-selected: hsl(214 98% 52%);
--wq-color-selected-border: hsl(214 98% 52%);
--wq-color-text-primary: hsl(220 20% 95%);
--wq-choice-chip-radius: 14px;
}

/* Scoped brand overrides */
.wq-question-choice.questionnaire-choice .wq-choice-option.wq-selected {
background: var(--wq-color-selected);
border-color: var(--wq-color-selected-border);
}

/* Optional: hide native radio/checkbox for pure chip look */
.wq-question-choice.questionnaire-choice input[type="radio"],
.wq-question-choice.questionnaire-choice input[type="checkbox"] {
position: absolute;
inline-size: 1px;
block-size: 1px;
opacity: 0;
pointer-events: none;
}

Keep overrides scoped to your choiceClassName and use package state classes (.wq-selected, .wq-disabled) rather than custom state selectors. See the package README for the full token and class reference.

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

Custom Text Answers (answerConstraint)

FHIR R5 allows choice questions to accept free-text "Other" responses using answerConstraint:

{
linkId: "referral-source",
type: "coding",
answerConstraint: "optionsOrString",
answerOption: [
{ valueCoding: { code: "search", display: "Search engine" } },
{ valueCoding: { code: "social", display: "Social media" } },
]
}
  • Single-select (repeats: false): Coded option or free text (mutually exclusive)
  • Multi-select (repeats: true): Coded options and free text coexist

Quantity Questions with Units

Use the standard FHIR questionnaire-unitOption extension:

{
linkId: "waist",
type: "quantity",
text: "What is your waist circumference?",
extension: [
{
url: "http://hl7.org/fhir/StructureDefinition/questionnaire-unitOption",
valueCoding: {
system: "http://unitsofmeasure.org",
code: "cm",
display: "cm",
},
},
{
url: "http://hl7.org/fhir/StructureDefinition/questionnaire-unitOption",
valueCoding: {
system: "http://unitsofmeasure.org",
code: "[in_i]",
display: "in",
},
},
]
}

Multiple units display as a toggle; single unit shows as label; no units falls back to simple decimal input.

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",
},
];
}