Saving and Resuming Questionnaire Responses
People do not always finish a form in one sitting. Saving a draft lets them pause and return to their answers later. This guide connects the activity-log example to Medplum so users can save progress, resume the same response, and submit it when they are ready.
QuestionnaireForm manages answers and page navigation in the browser. Your application saves the QuestionnaireResponse, loads it when the user returns, and starts processing after completion.
Before you begin, you'll need:
- An authenticated Medplum React application.
- Access to the patient whose response you will save.
Plan the response lifecycle
Keep one response per assigned instance of a form. A patient completing the same assessment at two visits usually needs two responses, while saving a draft several times should update the same response.
flowchart TD
D["Load the assigned form and response"]
D --> E["Edit answers in QuestionnaireForm"]
E -->|"Save draft"| S["Stored: in-progress"]
S -->|"Resume"| E
E -->|"Validate and save"| C["Stored: completed"]
C --> P["Extract resources or run a Bot"]
| Boundary | Form behavior | Application responsibility |
|---|---|---|
| Initialization | Loads initial answers and evaluates calculations; emits onChange. | Load the intended definition and response before editing. |
| Editing | Calls onChange with an in-progress answer snapshot. | Keep the latest snapshot and show whether it has been saved. |
| Page navigation | Changes the visible page. | Save explicitly or implement autosave. |
| Submission | Calls onSubmit with status: 'completed' and an authored timestamp. | Validate the whole response, then save before showing completion. |
Connect the activity-log example
The following steps connect the activity-log definition to a saved response. Use these files in the same directory of an existing Medplum React application:
| File | Responsibility |
|---|---|
advanced-questionnaires.ts | The definition from the authoring guide |
saving-responses.ts | The persistence and validation helpers below |
saved-questionnaire-form.tsx | The editor with Save draft and Submit |
activity-form.tsx | Loading the assigned form and opening the editor |
Imports for saving-responses.ts
import type { MedplumClient } from '@medplum/core';
import { deepClone, getQuestionnaireAnswers, getReferenceString } from '@medplum/core';
import type { Questionnaire, QuestionnaireResponse, Reference } from '@medplum/fhirtypes';
import { activityQuestionnaire } from './advanced-questionnaires';1. Store the form definition
Call await publishActivityQuestionnaire(medplum) in your application's setup workflow, then keep the returned Questionnaire ID in configuration. Repeating the call reuses the same definition. Replace the example identifier system with one owned by your application.
Helper: publish the example definition
export async function publishActivityQuestionnaire(medplum: MedplumClient): Promise<Questionnaire> {
const identifier = { system: 'https://example.org/questionnaire-definition', value: 'daily-activity-v1' };
return medplum.createResourceIfNoneExist<Questionnaire>(
{ ...activityQuestionnaire, status: 'active', version: '1', identifier: [identifier] },
new URLSearchParams({ identifier: `${identifier.system}|${identifier.value}` }).toString()
);
}Keep this definition unchanged once responses use it. To publish a new revision, use a new definition identifier and resource ID; see changing a form.
2. Create or load the assigned response
Use a stable assignment identifier to make draft creation safe to retry. Replace the example identifier system with one owned by your application, and reuse the same assignmentId when reopening or retrying that assignment.
Helper: create or reuse an assigned response
export async function getOrCreateDraft(
medplum: MedplumClient,
questionnaire: Questionnaire,
subject: Reference,
assignmentId: string
): Promise<QuestionnaireResponse> {
const questionnaireReference = getReferenceString(questionnaire);
if (!questionnaire.id || !questionnaireReference || !subject.reference) {
throw new Error('Load a stored Questionnaire and a subject reference before assigning a form.');
}
// Use a stable ID for this assigned form, including across retries.
const identifier = { system: 'https://example.org/form-assignment', value: assignmentId };
const response = await medplum.createResourceIfNoneExist<QuestionnaireResponse>(
{
resourceType: 'QuestionnaireResponse',
identifier,
questionnaire: questionnaireReference,
subject,
status: 'in-progress',
},
new URLSearchParams({ identifier: `${identifier.system}|${identifier.value}` }).toString()
);
if (response.subject?.reference !== subject.reference || response.questionnaire !== questionnaireReference) {
throw new Error('This assignment belongs to a different subject or Questionnaire.');
}
return response;
}The helper returns the saved resource, including its server-assigned id and meta. It also checks that a reused assignment belongs to the expected subject and definition. Give each assigned instance its own identifier, including when the same patient repeats the form at a later visit.
Load the stored definition and response together before mounting the editor:
export async function loadActivityForm(
medplum: MedplumClient,
questionnaireId: string,
patientId: string,
assignmentId: string
): Promise<{ questionnaire: Questionnaire; response: QuestionnaireResponse }> {
const questionnaire = await medplum.readResource('Questionnaire', questionnaireId);
const response = await getOrCreateDraft(medplum, questionnaire, { reference: `Patient/${patientId}` }, assignmentId);
return { questionnaire, response };
}If the response ID is already known, load it directly with medplum.readResource('QuestionnaireResponse', responseId) and load its assigned definition.
- Check the returned status. Conditional creation may return a previously completed response. Reopening it for amendment should be a deliberate workflow.
- Set the subject on the response itself. Include
encounter,source, andauthoraccording to your workflow. Access to patient data is enforced by AccessPolicies. - Keep the definition consistent. The helper stores
Questionnaire/{id}. Load the matching definition and avoid changing its question structure while responses are in progress. - Prefill in application code. To use patient or encounter data, load it and populate the matching nested response items before mounting the form. Follow the structure and answer types of the Questionnaire.
3. Preserve context when saving
The form initializes its answer state from the supplied response. Its callbacks do not round-trip all of that resource's root fields, such as id, meta, identifier, and encounter. Merge the edited snapshot with the saved resource before updating it:
Helper: update the saved response with version checking
export async function saveResponse(
medplum: MedplumClient,
saved: QuestionnaireResponse,
edited: QuestionnaireResponse
): Promise<QuestionnaireResponse> {
if (!saved.id || !saved.meta?.versionId) {
throw new Error('Load the saved response before updating it.');
}
return medplum.updateResource(
{
...saved,
...deepClone(edited),
// Retain the identity and context of the assigned form.
id: saved.id,
meta: saved.meta,
identifier: saved.identifier,
questionnaire: saved.questionnaire,
subject: saved.subject,
encounter: saved.encounter,
},
{ headers: { 'If-Match': `W/"${saved.meta.versionId}"` } }
);
}This helper keeps the assigned response's identity, subject, encounter, and Questionnaire reference. The If-Match header protects against overwriting a newer version saved by another editor. Keep the returned resource for the next save. On a 412 Precondition Failed, reconcile with the current server version before retrying; see version checking.
The editor below reports version conflicts and keeps edits visible. It leaves reconciliation to your application. Preserve or reconcile unsaved changes before loading the latest response with medplum.readResource and mounting a fresh editor. Retrying in the existing editing session sends the same stale version and fails again.
4. Validate completion across all pages
Drafts can be incomplete. Validate the whole response before saving it as completed. The component sets status: 'completed' in its submit callback; it does not perform a complete assessment of your form's rules. Next checks browser validity on the current page, and the final page does not contain the earlier pages' input controls.
For this example, both activity values must be non-negative whole numbers, and the total must match their sum:
export function validateActivityResponse(response: QuestionnaireResponse): void {
// These linkIds belong to this non-repeating example form.
const answers = getQuestionnaireAnswers(response);
const morning = answers['morning-minutes']?.valueInteger;
const afternoon = answers['afternoon-minutes']?.valueInteger;
if (
morning === undefined ||
afternoon === undefined ||
!Number.isSafeInteger(morning) ||
!Number.isSafeInteger(afternoon) ||
morning < 0 ||
afternoon < 0
) {
throw new Error('On the Activity page, enter a non-negative whole number for both morning and afternoon minutes.');
}
if (answers['total-minutes']?.valueInteger !== morning + afternoon) {
throw new Error('The calculated total does not match the activity answers.');
}
}This validator is specific to the activity log. For another form, validate its required enabled questions, answer types, ranges, and calculations. The editor below runs the supplied validator on completion and leaves the form open on failure. It still allows incomplete drafts. Enforce required business rules in your trusted server-side submission or processing workflow too; client-side checks provide respondent feedback.
5. Open the editor
Pass the saved response through the questionnaireResponse prop. The example below keeps a snapshot from onChange, saves drafts with a separate button, and persists the completed response from onSubmit.
- Clone snapshots. The form manages nested answer objects; copying the response keeps your save operation separate from later edits.
- Preserve one mounted editing session. Load a response before mounting, and use a React
keybased on its ID when switching to another response. Updating the response prop is not a controlled-state mechanism for replacing answers mid-session. - Pass context explicitly. Omitting
subjectleaves it undefined in the submit snapshot; the save helper restores the assignment's subject.subjectandencounteralso support reference-input filters. These props do not create%patientor%encounterFHIRPath variables. - Handle save failures visibly. Keep edits open and show success only after the write resolves. Distinguish retryable failures from version conflicts, which require the reconciliation and reload described above.
React example: explicit Save draft and Submit
import { deepClone, normalizeErrorString, normalizeOperationOutcome } from '@medplum/core';
import type { Questionnaire, QuestionnaireResponse } from '@medplum/fhirtypes';
import { QuestionnaireForm, useMedplum } from '@medplum/react';
import type { JSX } from 'react';
import { useRef, useState } from 'react';
import { saveResponse } from './saving-responses';export function SavedQuestionnaireForm(props: {
questionnaire: Questionnaire;
response: QuestionnaireResponse;
validateResponse: (response: QuestionnaireResponse) => void | Promise<void>;
}): JSX.Element {
const medplum = useMedplum();
const [saved, setSaved] = useState(props.response);
const [initialResponse] = useState(() => deepClone(props.response));
const latest = useRef<QuestionnaireResponse>(initialResponse);
const initialized = useRef(false);
const saving = useRef(false);
const [busy, setBusy] = useState(false);
const [message, setMessage] = useState('');
async function persist(response: QuestionnaireResponse): Promise<void> {
if (saving.current) {
// The fieldset disables UI actions; this guard also prevents overlapping calls.
return;
}
const snapshot = deepClone(response);
saving.current = true;
setBusy(true);
setMessage('Saving...');
try {
if (snapshot.status === 'completed') {
await props.validateResponse(snapshot);
}
const result = await saveResponse(medplum, saved, snapshot);
setSaved(result);
setMessage(result.status === 'completed' ? 'Response submitted.' : 'Draft saved.');
} catch (error) {
if (normalizeOperationOutcome(error).id === 'precondition-failed') {
setMessage(
'This response changed elsewhere. Preserve your edits, then reload the latest response before saving again.'
);
} else {
setMessage(`Save failed: ${normalizeErrorString(error)}. Your edits remain open.`);
}
} finally {
saving.current = false;
setBusy(false);
}
}
return (
<>
{saved.status === 'in-progress' && (
<fieldset disabled={busy} style={{ border: 0, padding: 0 }}>
<QuestionnaireForm
questionnaire={props.questionnaire}
questionnaireResponse={initialResponse}
subject={saved.subject}
encounter={saved.encounter}
source={saved.source}
onChange={(response) => {
latest.current = deepClone(response);
if (initialized.current) {
setMessage('Unsaved changes.');
}
initialized.current = true;
}}
onSubmit={(response) => {
persist(response).catch(console.error);
}}
/>
<button
type="button"
onClick={() => {
persist({ ...latest.current, status: 'in-progress' }).catch(console.error);
}}
>
Save draft
</button>
</fieldset>
)}
<p role="status">{message || (saved.status !== 'in-progress' ? 'This response is closed for editing.' : '')}</p>
</>
);
}The page below handles loading and errors, then supplies validateActivityResponse to the editor:
Complete activity-form.tsx page
import { normalizeErrorString } from '@medplum/core';
import type { Questionnaire, QuestionnaireResponse } from '@medplum/fhirtypes';
import { useMedplum } from '@medplum/react';
import type { JSX } from 'react';
import { useEffect, useState } from 'react';
import { SavedQuestionnaireForm } from './saved-questionnaire-form';
import { loadActivityForm, validateActivityResponse } from './saving-responses';
export function ActivityFormPage(props: {
questionnaireId: string;
patientId: string;
assignmentId: string;
}): JSX.Element {
const medplum = useMedplum();
const { questionnaireId, patientId, assignmentId } = props;
const requestKey = JSON.stringify([questionnaireId, patientId, assignmentId]);
const [loaded, setLoaded] = useState<{
key: string;
questionnaire?: Questionnaire;
response?: QuestionnaireResponse;
error?: string;
}>();
useEffect(() => {
let active = true;
loadActivityForm(medplum, questionnaireId, patientId, assignmentId)
.then((result) => {
if (active) {
setLoaded({ key: requestKey, ...result });
}
})
.catch((err) => {
if (active) {
setLoaded({ key: requestKey, error: normalizeErrorString(err) });
}
});
return () => {
active = false;
};
}, [medplum, questionnaireId, patientId, assignmentId, requestKey]);
if (loaded?.key === requestKey && loaded.error) {
return <p role="alert">Unable to load the form: {loaded.error}</p>;
}
if (loaded?.key !== requestKey || !loaded.questionnaire || !loaded.response) {
return <p role="status">Loading form...</p>;
}
return (
<SavedQuestionnaireForm
key={loaded.response.id}
questionnaire={loaded.questionnaire}
response={loaded.response}
validateResponse={validateActivityResponse}
/>
);
}Render <ActivityFormPage questionnaireId={questionnaireId} patientId={patientId} assignmentId={assignmentId} /> inside your Medplum and Mantine providers. Supply the published definition ID, the intended patient's ID, and the stable identifier for this assigned form. The page tracks these inputs to avoid displaying a previous assignment while loading another. This example uses explicit saves and closes editing after completion; add your application's navigation and review screen around it.
Try Save draft, unmount the page, and reopen it with the same assignment. The answers and response ID should remain the same. Submit valid answers and confirm that the saved status becomes completed.
When resuming, load the saved response again and mount a fresh form. The form starts on the first page with the saved answers; the current page index is not part of the response.
Callbacks omit disabled and hidden answers. Those answers can be restored locally if re-enabled during the same session, but a saved draft does not retain them. Root-level signature data is also not restored into the form's initial answer state. Test your signing and amendment workflow separately; see Consent and Signatures.
If you add autosave
Use the same saved response ID and save helper. Add a debounce and serialize writes so an older draft cannot finish after a newer draft or a completed submission. Account for the initialization onChange event, which happens before the user edits a field. Before navigating away, await the pending save or give the user a clear unsaved-changes choice.
Submit and process the completed response
The form's submit handler sets status: 'completed', updates authored, and uses the supplied subject and source. If source is omitted, it uses the signed-in profile when available. For staff entering a patient's answers, set source to the patient and author to the practitioner recording them. subject identifies the patient the answers are about.
After completion validation:
- Save, then process. Persist the completed response before invoking SDC template extraction or a Bot. Choose the processing path according to the mapping and workflow.
- Trigger on completion. Draft creation and repeated saves also produce resource events. Scope subscriptions to the intended Questionnaire and completed status; use transition criteria when appropriate.
- Handle retries. Make downstream writes idempotent. If extraction and a Bot both run, give each clear ownership of its outputs.
$extract returns a Bundle for a separate write step. Inspect write results before treating the downstream workflow as complete.
Change a form without breaking existing responses
A saved response depends on the definition's structure and the meaning of its questions. Adding a required item, changing a linkId or type, or changing a calculation can alter what happens when an old response is reopened.
For the resource-ID approach used in this example:
- Publish a new definition resource for a new revision. Give it a new definition identifier and a descriptive
Questionnaire.versionvalue. Changingversionon the same resource does not make aQuestionnaire/{id}reference point to an older definition. - Keep existing assignments on their original definition. Use the new Questionnaire ID for new assignments. Keep old definitions available for resumption and historical review.
- Release processing changes with the definition. Test expressions, completion validation, and extraction or Bot logic together before assigning the new revision.
- Make migration explicit. If an in-progress response must move to the new revision, map the answers and validate the result. Preserve completed responses as historical records and handle corrections through your amendment workflow.
Canonical URLs are another referencing strategy. If your application uses them, define how it resolves a specific version before replacing this example's resource-ID approach.
Check the whole journey
Before releasing a form, walk through it as an implementer and as a respondent:
- Enter answers, move forward and back, and check calculations after clearing an input.
- Enable a conditional question, answer it, disable it, and inspect the saved response.
- Save, close, and reopen. Confirm the response ID, subject, encounter, repeated groups, and answers are preserved.
- Simulate a failed save and a stale-version conflict; confirm the UI does not claim success or discard edits.
- Submit once, reopen the saved resource, and confirm only the intended downstream processing runs.
For form design and imported SDC features, see Advanced Questionnaire Authoring.