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/

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 usequestionnaire.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 inputstring/text- Text inputboolean- Yes/No radio buttons
Special Features
- Hidden Fields: Questions with
questionnaire-hiddenextension 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 linkIdvalueCoding?: { system?: string; code?: string; display?: string }- The option's value codingvalueInteger?: number- The option's integer value (if applicable)checked: boolean- Whether this option is currently selecteddisabled?: boolean- Whether this option is disabledonChange: () => void- Callback when the option is selected/toggledlabel: string- The display text for this optionindex: 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>
)}
/>
);
}

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