On this page

Advanced Questionnaire Authoring

Use advanced questionnaire features to break long forms into pages, show relevant questions, and calculate answers from earlier responses. This guide covers the behavior of Medplum's QuestionnaireForm React component and its useQuestionnaireForm hook.

Start with Modeling Questionnaires and Responses for item types, linkId, initial values, and basic enableWhen rules. To connect a form to storage, continue with Saving and Resuming Responses.

Choose the feature you need​

Medplum's form renderer supports a subset of Structured Data Capture (SDC). Use the table below to choose an approach, and check the feature compatibility table before importing a questionnaire from another authoring tool.

GoalUse
Show a question based on another answerenableWhen; set enableBehavior when combining conditions
Show a question using more complex logicSDC enableWhenExpression with FHIRPath
Calculate an answer from other answersSDC calculatedExpression with FHIRPath
Split a long form into stepsTop-level groups with the page item control
Collect several entries, such as multiple medicationsA repeating group; see intake questionnaire design
Prefill from patient or encounter dataLoad the data in your application and supply a QuestionnaireResponse

Build a two-page activity log​

Suppose your application asks patients to record their morning and afternoon activity in minutes. The form should calculate the total and ask a comparison question only when they recorded activity in both periods.

This example brings together three patterns you can reuse in other questionnaires:

  • Pages separate entering answers from reviewing them.
  • Calculated answers derive a total from earlier inputs.
  • Conditional questions show follow-ups when the relevant answers are present.
  flowchart LR
    A["Activity page<br/>Morning minutes<br/>Afternoon minutes"] --> B["Review page<br/>Calculated total<br/>Conditional comparison"]
    B --> C["Submit response"]

Try the complete example​

Save the definition below as advanced-questionnaires.ts to try all three features together. It uses @medplum/fhirtypes for TypeScript checking; its extensions are stored as ordinary Questionnaire JSON. After the preview, the following sections walk through each part of the definition.

Complete TypeScript definition
import type { Extension, Questionnaire, QuestionnaireItem } from '@medplum/fhirtypes';
export const activityInputs: QuestionnaireItem[] = [
  { linkId: 'morning-minutes', type: 'integer', text: 'Morning activity (minutes)', required: true },
  { linkId: 'afternoon-minutes', type: 'integer', text: 'Afternoon activity (minutes)', required: true },
];
const morningAnswer = "%resource.item.item.where(linkId = 'morning-minutes').answer.value";
const afternoonAnswer = "%resource.item.item.where(linkId = 'afternoon-minutes').answer.value";

export const totalMinutes: QuestionnaireItem = {
  linkId: 'total-minutes',
  type: 'integer',
  text: 'Total activity (minutes)',
  extension: [
    {
      url: 'http://hl7.org/fhir/uv/sdc/StructureDefinition/sdc-questionnaire-calculatedExpression',
      valueExpression: {
        language: 'text/fhirpath',
        expression: `iif(${morningAnswer}.empty(), 0, ${morningAnswer}) + iif(${afternoonAnswer}.empty(), 0, ${afternoonAnswer})`,
      },
    },
  ],
};
export const activityComparison: QuestionnaireItem = {
  linkId: 'activity-comparison',
  type: 'text',
  text: 'How did the morning and afternoon activities compare?',
  extension: [
    {
      url: 'http://hl7.org/fhir/uv/sdc/StructureDefinition/sdc-questionnaire-enableWhenExpression',
      valueExpression: {
        language: 'text/fhirpath',
        expression: `(${morningAnswer} > 0) and (${afternoonAnswer} > 0)`,
      },
    },
  ],
};
const pageControl: Extension = {
  url: 'http://hl7.org/fhir/StructureDefinition/questionnaire-itemControl',
  valueCodeableConcept: {
    coding: [{ system: 'http://hl7.org/fhir/questionnaire-item-control', code: 'page' }],
  },
};

export const activityQuestionnaire: Questionnaire = {
  resourceType: 'Questionnaire',
  status: 'draft',
  title: 'Daily activity log',
  subjectType: ['Patient'],
  item: [
    {
      linkId: 'activity',
      type: 'group',
      text: 'Activity',
      extension: [pageControl],
      item: activityInputs,
    },
    {
      linkId: 'review',
      type: 'group',
      text: 'Review',
      extension: [pageControl],
      item: [totalMinutes, activityComparison],
    },
  ],
};

In an existing Medplum React application, add this preview component and render <ActivityPreview /> inside your Medplum and Mantine providers:

import type { QuestionnaireResponse } from '@medplum/fhirtypes';
import { QuestionnaireForm } from '@medplum/react';
import type { JSX } from 'react';
import { useState } from 'react';
import { activityQuestionnaire } from './advanced-questionnaires';

export function ActivityPreview(): JSX.Element {
  const [response, setResponse] = useState<QuestionnaireResponse>();
  return (
    <>
      <QuestionnaireForm questionnaire={activityQuestionnaire} onChange={setResponse} onSubmit={setResponse} />
      <details>
        <summary>Current response</summary>
        <pre>{JSON.stringify(response, undefined, 2)}</pre>
      </details>
    </>
  );
}

Try this sequence:

  1. Enter 15 morning minutes and 25 afternoon minutes, then choose Next.
  2. The Review page opens with 40 total minutes and a comparison question. Expand Current response to inspect the calculated answer and its linkId.
  3. Choose Back, change afternoon minutes to 0, and choose Next again. The total becomes 15, and the comparison question disappears.
  4. Choose Submit. The preview response changes to completed in browser state. The next guide adds persistence and completion validation.

The preview exposes both the form and response data so you can compare them. See expression behavior and limits for calculated-field display considerations.

Define the inputs​

Use stable, unique linkId values so expressions and saved answers continue to identify the same questions. These two inputs will be placed in the first page's group:

export const activityInputs: QuestionnaireItem[] = [
  { linkId: 'morning-minutes', type: 'integer', text: 'Morning activity (minutes)', required: true },
  { linkId: 'afternoon-minutes', type: 'integer', text: 'Afternoon activity (minutes)', required: true },
];

Calculate an answer​

Add the calculatedExpression extension to the item receiving the result:

const morningAnswer = "%resource.item.item.where(linkId = 'morning-minutes').answer.value";
const afternoonAnswer = "%resource.item.item.where(linkId = 'afternoon-minutes').answer.value";

export const totalMinutes: QuestionnaireItem = {
  linkId: 'total-minutes',
  type: 'integer',
  text: 'Total activity (minutes)',
  extension: [
    {
      url: 'http://hl7.org/fhir/uv/sdc/StructureDefinition/sdc-questionnaire-calculatedExpression',
      valueExpression: {
        language: 'text/fhirpath',
        expression: `iif(${morningAnswer}.empty(), 0, ${morningAnswer}) + iif(${afternoonAnswer}.empty(), 0, ${afternoonAnswer})`,
      },
    },
  ],
};

The TypeScript constants build an expression that selects both inputs and adds their values. For example, 15 morning minutes + 25 afternoon minutes = 40 total minutes. Each iif() supplies zero when its input is unanswered. Define the appropriate incomplete-answer behavior for your own assessment.

Show a question using an expression​

Use basic enableWhen for straightforward comparisons. When the condition needs FHIRPath, add enableWhenExpression to the question or group:

export const activityComparison: QuestionnaireItem = {
  linkId: 'activity-comparison',
  type: 'text',
  text: 'How did the morning and afternoon activities compare?',
  extension: [
    {
      url: 'http://hl7.org/fhir/uv/sdc/StructureDefinition/sdc-questionnaire-enableWhenExpression',
      valueExpression: {
        language: 'text/fhirpath',
        expression: `(${morningAnswer} > 0) and (${afternoonAnswer} > 0)`,
      },
    },
  ],
};

This question appears only when both activity inputs are positive. The expression reads the whole response, so it works across the two pages. Return a Boolean and test the unanswered state as well as true and false results. Use one condition mechanism per item; an expression takes precedence over enableWhen when both are present.

Disabled questions are omitted from callback responses. See expression behavior and limits for how this affects saving and resuming.

Arrange the items into pages​

Wrap each page in a top-level group and give it a questionnaire-itemControl extension with code page. Assemble the items defined above:

const pageControl: Extension = {
  url: 'http://hl7.org/fhir/StructureDefinition/questionnaire-itemControl',
  valueCodeableConcept: {
    coding: [{ system: 'http://hl7.org/fhir/questionnaire-item-control', code: 'page' }],
  },
};

export const activityQuestionnaire: Questionnaire = {
  resourceType: 'Questionnaire',
  status: 'draft',
  title: 'Daily activity log',
  subjectType: ['Patient'],
  item: [
    {
      linkId: 'activity',
      type: 'group',
      text: 'Activity',
      extension: [pageControl],
      item: activityInputs,
    },
    {
      linkId: 'review',
      type: 'group',
      text: 'Review',
      extension: [pageControl],
      item: [totalMinutes, activityComparison],
    },
  ],
};
  • Mark the first group as a page. The renderer uses the first item's control to enable pagination, then treats each top-level item as a page. Keep all top-level items as page groups.
  • Use the group's text as the page title. Short titles make the step indicator easier to scan.
  • Put conditions inside a page. The step list is fixed; a disabled page group can leave an empty step rather than being skipped.
  • Use Next, Back, and Submit. Next checks browser validity on the current page. Submit appears on the last page. Page navigation alone does not save a response.
  • Set disablePagination to show all groups together. This is also useful for reviewing the full form during development.

The example's Review page shows the calculated total when that page mounts. Test returning to Activity, changing an answer, and navigating forward again.

Expression behavior and limits​

Expressions run when the response initializes and when answers change. Use language: 'text/fhirpath'; these form extensions do not evaluate CQL or run FHIR search queries. Keep these rules in mind:

  • %resource is the entire QuestionnaireResponse. It is also the expression's starting context, even when the calculation is on a nested item.
  • Follow the response structure. item.where(...) finds top-level items; item.item.where(...) finds questions inside this example's page groups. Add the appropriate path for deeper groups or answers nested under answer.item.
  • Test the functions you use. Medplum's FHIRPath evaluator implements a subset of the language. For example, this calculation uses iif() and arithmetic; sum() and repeat() are not implemented in the current evaluator.
  • Return one value of the item's answer type. The renderer uses the first expression result and writes one answer. Use an integer result for an integer item, for example.
  • Handle cleared inputs. An expression that returns an empty collection leaves the previous calculated answer in place. Use a deliberate fallback or application logic when the result should be cleared.
  • Keep dependencies simple. Evaluation walks items in definition order once per change. Put a calculated dependency before its consumers; avoid circular calculations. Repeated-group calculations need additional care, as described below.
Calculated data and its display

Calculated expressions update the response data. Check the display separately:

  • Current text and number inputs use initial display values, so an already-mounted input may not visibly refresh when its calculated answer changes.
  • The renderer does not enforce item.readOnly. For a live, non-editable summary, render the calculated value from onChange in your application.

Verify both the display and saved response. Validate calculations that drive clinical or workflow decisions in server-side processing.

When a condition becomes false, the form keeps the previous answer in its local editing state so it can reappear if the condition becomes true again. Disabled items are removed from onChange and onSubmit responses. After saving and reopening, an omitted answer is no longer available to restore.

Hidden items

The current renderer treats questionnaire-hidden: true as disabled and removes those items from callback responses too. Use application-managed context for values you need to retain without displaying them.

Use repeating items deliberately​

repeats: true on a question collects several answers to that question. On a group, it collects several instances of the entire group. Keep the nested structure intact when saving and extracting responses; see Intake Questionnaires.

The current calculated-expression evaluator matches the first response item for each linkId at each level. It does not calculate a separate result in every repeated group. Basic enableWhen lookups also find the first matching question rather than establishing a local context for each repetition. For calculations or conditions specific to each repeated row, use application logic or a custom renderer and test multiple repetitions.

Supported features and imported forms​

This table describes the current Medplum form renderer. SDC covers a wider set of capabilities, and support for a feature in an imported definition does not follow from successfully loading that definition.

FeatureCurrent behavior
initial, answerOption.initialSelectedSupplies initial answers; existing non-empty response answers take precedence.
enableWhen, enableBehaviorEvaluates answer-based conditions; account for the repeated-group behavior above.
SDC enableWhenExpressionEvaluates FHIRPath against the response.
SDC calculatedExpressionEvaluates FHIRPath and stores the first result as an answer, with the calculation and display limitations above.
questionnaire-itemControlSupports page groups and choice controls including radio buttons, checkboxes, and dropdowns. Other control codes do not guarantee a matching widget.
answerValueSetLoads choices from a ValueSet. Verify the terminology is available in the target project.
questionnaire-optionExclusiveAn exclusive answer option clears other selections; selecting another option clears the exclusive choice.
questionnaire-hiddenHides the item and excludes it from callback responses.
questionnaire-referenceResource, questionnaire-referenceFilterConfigures reference inputs. Filters can use $subj and $encounter from the component props.
item.readOnlyNot enforced by the current renderer; implement read-only presentation in your application.
SDC initialExpression, launchContext, variable, answerExpressionNo built-in evaluation in this form renderer. Load context and populate answers or choices in application code. %patient and %encounter are not supplied as expression variables.
Signature captureSupported on the single-page form; see Consent and Signatures. The paginated branch currently does not render the signature input.
SDC template extractionA separate server operation; see Parsing Questionnaire Responses. Form submission alone does not invoke it.

Use this as a feature-level compatibility checklist. When importing a questionnaire:

  1. Inventory its extensions, expression languages, terminology dependencies, and nested or repeating structures.
  2. Check each required feature against the table and test it in the Medplum version you deploy.
  3. Compare the visible form, callback data, and saved response using representative answers, including cleared and disabled answers.
  4. Keep the tested definition available for existing responses. Follow the form revision workflow when changing question structure, calculations, or extraction rules.

Continue with Saving and Resuming Responses to connect this form to an application workflow.