Branching Forms with `visibleIf`: A Medical Intake Example
Use the visibleIf predicate to show questions only when prior answers make them relevant. Every operator, with a working medical-intake example.
Nobody should scroll past pregnancy questions that do not apply to them, or an allergy follow-up when they declared no allergies. visibleIf is a small predicate language that gates a question on earlier answers, evaluated in the browser. Every operator appears below, on one medical intake form.
Why it runs in the browser
Branching on the server means a round trip between every answer. Patients feel that, and they abandon the form.
visibleIf runs in the embed on every keystroke, so the next relevant question appears at once. Four properties fall out of that. Predicates are pure, so the same answers always produce the same questions. They can only reference questions earlier in the array. They never throw; an unknown id resolves as unanswered. And hidden questions never submit, so a follow-up that was answered and then hidden is dropped from the payload.
The six shapes
VisibilityRule is a tagged union. Four leaves check one earlier question; two composers take arrays of nested rules.
{ "questionId": "smoker", "equals": "yes" }
Matches when smoker is exactly "yes".
{ "questionId": "smoker", "notEquals": "no" }
Matches when smoker is anything but "no". Not when it is unanswered, which catches people out; see below.
{ "questionId": "symptom", "in": ["pain", "infection"] }
Matches when symptom is any of the listed values.
{ "questionId": "symptom", "answered": true }
Matches when symptom has a non-empty answer.
{ "allOf": [{ "questionId": "smoker", "equals": "yes" }, { "questionId": "age_band", "in": ["18-44"] }] }
AND. Every nested rule must match.
{ "anyOf": [{ "questionId": "smoker", "equals": "yes" }, { "questionId": "wants_to_quit", "equals": "yes" }] }
OR. At least one must match.
Composers nest freely. Leaves are scalar only: equals and notEquals take one string | number, never an object or a comparator.
equals, for surgery in the past year
One yes/no question gating one follow-up:
{
"id": "surgery_date",
"type": "date",
"label": "Approximate date of your most recent surgery",
"visibleIf": { "questionId": "had_surgery", "equals": "yes" }
}
surgery_date appears only on a "yes". On "no", or before they get there, it stays hidden and submits nothing.
notEquals, for a declined consent
Sometimes you want the follow-up on anything but one answer. Somebody who consented does not need asking why they declined. Say vaccine_consent offers consent, decline and ask_doctor:
{
"id": "decline_reason",
"type": "text",
"label": "What's the reason you'd like to skip or discuss the vaccine?",
"multiline": true,
"visibleIf": { "questionId": "vaccine_consent", "notEquals": "consent" }
}
One predicate covers both "decline" and "ask_doctor". What it does before anybody answers is the subject of the gotchas section.
in, for symptom triage
With three or four values triggering the same follow-up, in beats chaining equals rules. Say current_symptom offers wellness, pain, infection, chronic-condition and mental-health:
{
"id": "current_medications",
"type": "text",
"multiline": true,
"label": "List any medications you're currently taking, including dose",
"visibleIf": {
"questionId": "current_symptom",
"in": ["pain", "infection", "chronic-condition"]
}
}
The medication list appears for pain, infection and chronic-condition, and not for the other two. The same logic written the long way:
{
"anyOf": [
{ "questionId": "current_symptom", "equals": "pain" },
{ "questionId": "current_symptom", "equals": "infection" },
{ "questionId": "current_symptom", "equals": "chronic-condition" }
]
}
Three rules where one would do.
answered, for revealing one question at a time
When the content of the answer does not matter and you just want them to finish the previous question first. It suits free-text fields, where there is no value to match on:
{
"id": "complaint_duration",
"type": "multiple-choice",
"label": "How long have you had this?",
"options": [
{ "value": "today", "label": "Started today" },
{ "value": "days", "label": "A few days" },
{ "value": "weeks", "label": "A few weeks" },
{ "value": "months", "label": "Months or longer" }
],
"visibleIf": { "questionId": "primary_complaint", "answered": true }
}
The duration question waits until something is typed into the complaint box. Empty string, empty array and null all count as unanswered.
allOf, for two conditions at once
AND across every nested rule. Pregnancy questions gated on both sex assigned at birth and an age band are the standard case.
There is no gte or lte here, and equals takes a scalar only. To gate on age, band it into a multiple-choice question and in-match the bands. Say age_band offers under-12, 12-17, 18-44, 45-54 and 55-plus:
{
"id": "pregnancy_status",
"type": "multiple-choice",
"label": "Are you currently pregnant or could you be?",
"options": [
{ "value": "yes", "label": "Yes" },
{ "value": "possibly", "label": "Possibly" },
{ "value": "no", "label": "No" },
{ "value": "unsure", "label": "Unsure" }
],
"visibleIf": {
"allOf": [
{ "questionId": "sex_assigned_at_birth", "equals": "female" },
{ "questionId": "age_band", "in": ["12-17", "18-44", "45-54"] }
]
}
}
Pregnancy status appears only for a female-assigned-at-birth patient in a plausible age band. equals: { gte: 12 } is not a thing; banding plus in is how this is done.
anyOf, for any one of several signals
OR across nested rules. Any one of three allergy flags should open the deeper questionnaire. Say prior_allergic_reaction, family_severe_allergy and carries_epipen all came earlier:
{
"id": "allergy_intake_consent",
"type": "multiple-choice",
"label": "We'd like to ask a few more allergy-related questions. Continue?",
"options": [
{ "value": "yes", "label": "Yes" },
{ "value": "skip", "label": "Skip for now" }
],
"visibleIf": {
"anyOf": [
{ "questionId": "prior_allergic_reaction", "equals": "yes" },
{ "questionId": "family_severe_allergy", "equals": "yes" },
{ "questionId": "carries_epipen", "equals": "yes" }
]
}
}
Any single signal opens the branch.
Nesting the two
Composers nest, which is where real clinical rules become expressible in JSON.
A cessation referral should reach current smokers who show some sign of wanting to quit: an AND of smoking status with an OR of two intent signals. Say smoker_status is yes/former/no, wants_to_quit is yes/maybe/no, and quit_attempts runs none through many:
{
"id": "cessation_referral",
"type": "multiple-choice",
"label": "Would you like a referral to our smoking cessation program?",
"options": [
{ "value": "yes", "label": "Yes, please" },
{ "value": "info", "label": "Just send me information" },
{ "value": "no", "label": "Not at this time" }
],
"visibleIf": {
"allOf": [
{ "questionId": "smoker_status", "equals": "yes" },
{
"anyOf": [
{ "questionId": "wants_to_quit", "equals": "yes" },
{ "questionId": "wants_to_quit", "equals": "maybe" },
{ "questionId": "quit_attempts", "in": ["one", "two-three", "many"] }
]
}
]
}
}
Currently smoking, and either wanting to quit, unsure, or having tried before. The nested OR is what saves you asking a blunt “do you want a referral?” question first.
Cascades
A follow-up can gate its own follow-up, three or four levels down, as long as each predicate only looks backwards. Three questions, three levels:
{
"id": "allergen_types",
"type": "multiple-choice",
"label": "What are you allergic to? Select all that apply.",
"multiple": true,
"options": [
{ "value": "medications", "label": "Medications" },
{ "value": "foods", "label": "Foods" },
{ "value": "insects", "label": "Insect stings" },
{ "value": "environmental", "label": "Environmental (pollen, dust, etc.)" }
],
"visibleIf": { "questionId": "has_allergies", "equals": "yes" }
}
{
"id": "medication_allergy_severity",
"type": "range",
"label": "How severe is your worst medication allergy reaction?",
"min": 1,
"max": 10,
"unit": "/10",
"visibleIf": { "questionId": "allergen_types", "equals": "medications" }
}
The severity prompt fires when medications is among the selected allergens. Note that equals against a multi-select matches if the value is one of the selections, which the next section covers properly.
Three things that catch people
Forward references are rejected
A predicate must name a question earlier in the array. Name a later one and the questionnaire fails validation before anything renders. There is no gating Q3 on Q5; reorder, or rethink the flow.
An unanswered question is false for every leaf
The rule that bites: notEquals: "no" evaluates false on an unanswered question. The intuitive read is true, and the intuitive read is wrong. Any leaf predicate against an unanswered question returns false, whatever the operator.
So this:
{ "questionId": "vaccine_consent", "notEquals": "consent" }
hides the follow-up until consent is answered. Usually that is what you wanted, since a “why are you declining?” field flashing up before the consent question is asked reads as a bug.
When you really do want it visible before the question is asked, say so:
{
"anyOf": [
{ "questionId": "smoker_status", "notEquals": "no" },
{ "questionId": "smoker_status", "answered": false }
]
}
answered: false matches the unanswered case explicitly, so the OR covers both.
Multi-select answers are arrays
With multiple: true the answer is string[]. The leaves cope, in a way worth knowing:
equals: "X" matches whenever "X" is among the values; the array does not have to be exactly ["X"]. in matches if any selection appears in its list. notEquals: "X" matches when "X" is absent. And answered: true needs a non-empty array.
So gating on one allergen out of a multi-select is the obvious equals: "medications", with no special operator.
Testing predicates without a browser
Building a preview tool, a test harness or your own editor? Two pure helpers come out of quests-embed:
import { getVisibleQuestions, evaluateRule } from "@qaiddev/quests-embed";
const visible = getVisibleQuestions(questionnaire, answers);
const showCessation = evaluateRule(rule, answers);
Neither throws, and an unknown id resolves as unanswered, which makes them easy to assert against: given these answers, exactly these questions.
goToStep respects the predicates
goToStep(questionId) jumps to a question:
const ok = embed.goToStep("medication_list");
It returns true when the question was visible and the embed moved. It returns false when a predicate is unmet or the id is unknown, and in both cases nothing moves.
That false is what an editor preview needs. An admin clicks a question, you call goToStep, and on a false you can tell them which earlier answer would reveal it instead of appearing to do nothing.
The whole form
A complete intake questionnaire. Drop it into your embed config and it works. Every operator above appears in it at least once.
The flow:
An always-visible identity block, then the visit reason gating the medication list. Pregnancy behind an allOf. Surgery history as an equals cascade. Allergies three levels deep, ending in a per-category severity prompt. Smoking behind a nested allOf and anyOf. Consent with a notEquals for the decline reason.
{
"id": "primary-care-intake",
"title": "New Patient Intake",
"submitLabel": "Submit intake",
"questions": [
{ "id": "full_name", "type": "text", "label": "Full legal name", "required": true },
{ "id": "date_of_birth", "type": "date", "label": "Date of birth", "required": true },
{
"id": "sex_assigned_at_birth",
"type": "multiple-choice",
"label": "Sex assigned at birth",
"required": true,
"options": [
{ "value": "female", "label": "Female" },
{ "value": "male", "label": "Male" },
{ "value": "intersex", "label": "Intersex" },
{ "value": "prefer-not", "label": "Prefer not to say" }
]
},
{
"id": "age_band",
"type": "multiple-choice",
"label": "Age range",
"required": true,
"options": [
{ "value": "under-12", "label": "Under 12" },
{ "value": "12-17", "label": "12-17" },
{ "value": "18-44", "label": "18-44" },
{ "value": "45-54", "label": "45-54" },
{ "value": "55-plus", "label": "55+" }
]
},
{
"id": "current_symptom",
"type": "multiple-choice",
"label": "Primary reason for your visit today?",
"required": true,
"options": [
{ "value": "wellness", "label": "Routine wellness check" },
{ "value": "pain", "label": "New or worsening pain" },
{ "value": "infection", "label": "Possible infection" },
{ "value": "chronic-condition", "label": "Chronic condition follow-up" },
{ "value": "mental-health", "label": "Mental health concern" }
]
},
{
"id": "primary_complaint",
"type": "text",
"multiline": true,
"label": "In your own words, what's bothering you?"
},
{
"id": "complaint_duration",
"type": "multiple-choice",
"label": "How long have you had this?",
"options": [
{ "value": "today", "label": "Started today" },
{ "value": "days", "label": "A few days" },
{ "value": "weeks", "label": "A few weeks" },
{ "value": "months", "label": "Months or longer" }
],
"visibleIf": { "questionId": "primary_complaint", "answered": true }
},
{
"id": "current_medications",
"type": "text",
"multiline": true,
"label": "List any medications you're currently taking, including dose",
"visibleIf": {
"questionId": "current_symptom",
"in": ["pain", "infection", "chronic-condition"]
}
},
{
"id": "pregnancy_status",
"type": "multiple-choice",
"label": "Are you currently pregnant or could you be?",
"options": [
{ "value": "yes", "label": "Yes" },
{ "value": "possibly", "label": "Possibly" },
{ "value": "no", "label": "No" },
{ "value": "unsure", "label": "Unsure" }
],
"visibleIf": {
"allOf": [
{ "questionId": "sex_assigned_at_birth", "equals": "female" },
{ "questionId": "age_band", "in": ["12-17", "18-44", "45-54"] }
]
}
},
{
"id": "had_surgery",
"type": "multiple-choice",
"label": "Have you had surgery in the past 12 months?",
"options": [
{ "value": "yes", "label": "Yes" },
{ "value": "no", "label": "No" }
]
},
{
"id": "surgery_date",
"type": "date",
"label": "Approximate date of your most recent surgery",
"visibleIf": { "questionId": "had_surgery", "equals": "yes" }
},
{
"id": "has_allergies",
"type": "multiple-choice",
"label": "Do you have any known allergies?",
"options": [
{ "value": "yes", "label": "Yes" },
{ "value": "no", "label": "No" }
]
},
{
"id": "allergen_types",
"type": "multiple-choice",
"label": "What are you allergic to? Select all that apply.",
"multiple": true,
"options": [
{ "value": "medications", "label": "Medications" },
{ "value": "foods", "label": "Foods" },
{ "value": "insects", "label": "Insect stings" },
{ "value": "environmental", "label": "Environmental" }
],
"visibleIf": { "questionId": "has_allergies", "equals": "yes" }
},
{
"id": "medication_allergy_severity",
"type": "range",
"label": "How severe is your worst medication allergy reaction?",
"min": 1,
"max": 10,
"unit": "/10",
"visibleIf": { "questionId": "allergen_types", "equals": "medications" }
},
{
"id": "smoker_status",
"type": "multiple-choice",
"label": "Do you currently use tobacco products?",
"options": [
{ "value": "yes", "label": "Yes, currently" },
{ "value": "former", "label": "Former smoker" },
{ "value": "no", "label": "Never" }
]
},
{
"id": "wants_to_quit",
"type": "multiple-choice",
"label": "Are you interested in quitting?",
"options": [
{ "value": "yes", "label": "Yes" },
{ "value": "maybe", "label": "Maybe" },
{ "value": "no", "label": "No" }
],
"visibleIf": { "questionId": "smoker_status", "equals": "yes" }
},
{
"id": "quit_attempts",
"type": "multiple-choice",
"label": "How many serious quit attempts have you made?",
"options": [
{ "value": "none", "label": "None" },
{ "value": "one", "label": "One" },
{ "value": "two-three", "label": "Two or three" },
{ "value": "many", "label": "More than three" }
],
"visibleIf": { "questionId": "smoker_status", "equals": "yes" }
},
{
"id": "cessation_referral",
"type": "multiple-choice",
"label": "Would you like a referral to our cessation program?",
"options": [
{ "value": "yes", "label": "Yes, please" },
{ "value": "info", "label": "Just send me information" },
{ "value": "no", "label": "Not at this time" }
],
"visibleIf": {
"allOf": [
{ "questionId": "smoker_status", "equals": "yes" },
{
"anyOf": [
{ "questionId": "wants_to_quit", "equals": "yes" },
{ "questionId": "wants_to_quit", "equals": "maybe" },
{ "questionId": "quit_attempts", "in": ["one", "two-three", "many"] }
]
}
]
}
},
{
"id": "vaccine_consent",
"type": "multiple-choice",
"label": "Do you consent to a flu vaccination today, if recommended?",
"options": [
{ "value": "consent", "label": "Yes, I consent" },
{ "value": "decline", "label": "I decline" },
{ "value": "ask_doctor", "label": "I'd like to discuss with the doctor first" }
]
},
{
"id": "decline_reason",
"type": "text",
"multiline": true,
"label": "What's the reason you'd like to skip or discuss the vaccine?",
"visibleIf": { "questionId": "vaccine_consent", "notEquals": "consent" }
}
]
}
Read it top to bottom and no predicate ever points forward. medication_allergy_severity sits behind three gates and no cycles. cessation_referral is the hairiest one: smoking, and wanting to quit or being unsure or having tried. And decline_reason catches both decline and ask_doctor while staying hidden until one is picked.