Qaid
ARTICLE

Branching Forms with `visibleIf`: A Medical Intake Example

Use the visibleIf predicate to show questions only when prior answers make them relevant. Equals, in, answered, allOf, anyOf — every operator with a real medical-intake example.

Qaid Team

A primary-care intake form is the hardest kind of form to get right. Patients shouldn’t have to scroll past pregnancy questions if they aren’t relevant, allergy follow-ups if no allergies were declared, or surgery-date prompts if they’ve never had surgery. quests-embed solves this with visibleIf — a small, declarative predicate language that gates each question on prior answers, evaluated entirely in the browser.

This guide walks through every operator with a working medical-intake example.

Why visibleIf

Branching at the server boundary — submit, return the next page — adds a network round trip between every answer. Patients notice. They abandon. The intake nurse hears about it.

visibleIf evaluates client-side, in the embed itself, on every keystroke or selection. The next relevant question slides in immediately. Hidden questions never render and never submit a value. From the patient’s perspective there is one form; from your perspective there are dozens of conditional paths.

A few defining properties:

  • Predicates are pure. Given the same answers map, the same questions appear. There’s no hidden state, no time-of-day, no random sampling.
  • Predicates can only reference questions earlier in the array. Forward references are rejected.
  • Predicates never throw. An unknown questionId resolves as if the question is unanswered.
  • Hidden questions don’t submit. If a follow-up was visible, then becomes hidden, the answer is dropped from the submission payload.

The Predicate Shape

The VisibilityRule type is a tagged union with six shapes. Five are leaf predicates that check a single prior question; two are composers (allOf and anyOf) that take arrays of nested rules.

{ "questionId": "smoker", "equals": "yes" }

Matches when smoker answered exactly "yes".

{ "questionId": "smoker", "notEquals": "no" }

Matches when smoker is anything other than "no" (including unanswered — see Gotchas).

{ "questionId": "symptom", "in": ["pain", "infection"] }

Matches when symptom is one of the listed values.

{ "questionId": "symptom", "answered": true }

Matches when symptom has any 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 nested rule must match.

You can nest composers freely. The leaf shapes are scalar-only — equals and notEquals take a single string | number, not an object or comparator.

equals — Surgery in the Past Year

The simplest case: a yes/no question gates a single follow-up.

{
  "id": "surgery_date",
  "type": "date",
  "label": "Approximate date of your most recent surgery",
  "visibleIf": { "questionId": "had_surgery", "equals": "yes" }
}

In English: show surgery_date only when the patient answered "yes" to had_surgery. If they answered "no", or haven’t gotten there yet, the date question stays hidden and never submits a value.

notEquals — Reason Declined

Sometimes you want the follow-up when the answer is not a particular value. A consent question is the canonical example: if the patient consented, you don’t need to ask why they declined. Assume vaccine_consent has options 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" }
}

In English: show the reason field whenever the consent answer is anything other than "consent" — covering both "decline" and "ask_doctor" in one predicate. See Gotchas for what happens when vaccine_consent is unanswered.

in — Symptom Triage

When you have one question and three or four follow-up triggers, in is dramatically cleaner than chaining anyOf with multiple equals rules. Assume current_symptom has options 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"]
  }
}

In English: show the medication list when the patient picked pain, infection, or chronic-condition — but not wellness or mental-health. Compare to the equivalent anyOf form:

{
  "anyOf": [
    { "questionId": "current_symptom", "equals": "pain" },
    { "questionId": "current_symptom", "equals": "infection" },
    { "questionId": "current_symptom", "equals": "chronic-condition" }
  ]
}

Three rules of boilerplate for the same logic. in is the right tool here.

answered — Progressive Disclosure

answered: true is the predicate you reach for when the content of the answer doesn’t matter — you just want to wait for the patient to finish one question before showing the next. It pairs beautifully with free-text fields.

{
  "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 }
}

In English: don’t show the duration question until the patient has typed something into the complaint box. This keeps the card clean — one question at a time — without rejecting empty answers as a validation error. Empty string, empty array, and null all count as unanswered.

Composing with allOf

allOf is AND. Every nested rule must match. The classic use is gating pregnancy-related questions by both sex assigned at birth and an age band.

A subtle point: equals only takes a scalar. There’s no gte, lte, or range comparator. To gate on age, convert age into a banded multiple-choice question and in-match the bands you care about. Assume age_band has options like under-12, 12-17, 18-44, 45-54, 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"] }
    ]
  }
}

In English: show pregnancy status only if the patient is female-assigned-at-birth AND falls in a plausible reproductive-age band. Don’t reach for equals: { gte: 12 } — that operator does not exist. Banded multiple-choice plus in is the supported pattern.

Composing with anyOf

anyOf is OR. At least one nested rule must match. Allergy follow-ups are a classic case — any one of several flags should surface the deeper allergy questionnaire. Assume three earlier yes/no questions: prior_allergic_reaction, family_severe_allergy, and carries_epipen.

{
  "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" }
    ]
  }
}

In English: show the deeper allergy intake if the patient has reacted before, OR has a family history, OR carries an EpiPen. Any single signal triggers the branch.

Nesting allOf and anyOf

The composers nest. This is where visibleIf earns its keep — you can express genuinely clinical rules without leaving JSON.

A smoking-cessation referral should appear only for current smokers who show some signal of wanting to quit. That’s an AND of “smoker” with an OR of two intent signals. Assume three earlier questions: smoker_status (yes/former/no), wants_to_quit (yes/maybe/no), and quit_attempts (none/one/two-three/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"] }
        ]
      }
    ]
  }
}

In English: show the cessation-referral question if (the patient is currently smoking) AND (they want to quit, OR they’re unsure but open, OR they’ve tried before). The nested OR captures clinical nuance without forcing the form to ask a literal “do you want a referral” gating question first.

Multi-Level Cascades

Branches can chain. Each follow-up can itself gate further follow-ups, three or four levels deep, as long as each predicate references only questions that came before it. A clean allergy cascade — 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 is gated by medications being among the selected allergens — and crucially, equals: "medications" against a multi-select question matches if "medications" is one of the selected values. See the next section for the full rule.

Gotchas

A handful of subtleties trip up most people on the first pass. Read this section twice.

Forward references are rejected

A predicate must reference a questionId that appears earlier in the questions array. Reference a later question and the questionnaire is rejected at validation time, before the embed renders. There’s no way to gate Q3 on Q5. Reorder, or rethink the flow.

Unanswered fields evaluate as false for atom predicates

This is the big one. notEquals: "no" is FALSE when the question hasn’t been answered yet — not TRUE, which would be the intuitive read. The reasoning: an atom predicate against an unanswered question always returns false, regardless of the operator.

So if you write:

{ "questionId": "vaccine_consent", "notEquals": "consent" }

And vaccine_consent is still unanswered, the follow-up is hidden, not shown. Usually that’s what you want — you don’t want the “why are you declining?” field to flash in before the patient answers the consent question.

But sometimes you genuinely want “show this unless the answer is no, including before the question is asked.” The right idiom is:

{
  "anyOf": [
    { "questionId": "smoker_status", "notEquals": "no" },
    { "questionId": "smoker_status", "answered": false }
  ]
}

Translation: show this if the answer is anything-but-no, OR if the question hasn’t been answered yet. answered: false matches the unanswered case explicitly.

Multi-select answers are arrays

When a multiple-choice question has multiple: true, the answer is string[], not a string. The leaf predicates handle this for you — but in a way you should understand:

  • equals: "X" matches if "X" is one of the values in the array. It doesn’t require the array to be ["X"] exactly.
  • in: ["X", "Y"] matches if any of the selected options is in ["X", "Y"].
  • notEquals: "X" matches if "X" is not in the selected array.
  • answered: true matches if the array is non-empty (an empty array counts as unanswered).

This means you can gate on a single allergen out of a multi-select list with the natural-looking equals: "medications" — no special-cased operator needed.

getVisibleQuestions and evaluateRule are exported

If you’re building a preview tool, a test harness, or your own questionnaire editor, the package exports two pure helpers from quests-embed:

import { getVisibleQuestions, evaluateRule } from "@qaiddev/quests-embed";

const visible = getVisibleQuestions(questionnaire, answers);
const showCessation = evaluateRule(rule, answers);

Both are pure functions over the answers map. They never throw; an unknown questionId resolves as unanswered. Great for unit tests where you want to assert “given these answers, exactly these questions should be visible.”

goToStep and Hidden Questions

The embed’s goToStep(questionId) method is the host-driven counterpart to visibleIf. Call it to jump the patient to a specific question:

const ok = embed.goToStep("medication_list");

Returns true if the question is currently visible (and the embed navigated to it). Returns false if the question is gated by an unmet visibleIf predicate, or if the questionId is unknown — in either case the navigation is rejected and the embed stays put.

This is the right hook for editor previews: an admin clicks a question in the editor, you call goToStep, and if the predicate is unmet you can show a “this question isn’t currently visible — answer X first” hint instead of silently doing nothing.

Full Annotated Example

Below is a complete intake questionnaire — drop it into your embed config and it works end to end. Every operator from this guide appears at least once.

The flow at a glance:

  1. Always-visible identity block — name, date of birth, sex assigned at birth, age band.
  2. Visit reason — gates the medication list.
  3. Pregnancy gateallOf of sex-assigned-at-birth and age band.
  4. Surgery historyequals cascade.
  5. Allergy cascade — three levels, ending in a per-category severity prompt.
  6. Smoking — nested allOf / anyOf for the cessation referral.
  7. ConsentnotEquals for the decline-reason follow-up.
{
  "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" }
    }
  ]
}

A few notes:

  • Every visibleIf references a question that appears earlier in the array — read top-to-bottom and no predicate ever points forward.
  • The medication_allergy_severity question only fires for patients who selected the medications allergen, which is itself only visible after they declared they have allergies. Three nested gates, no cycles.
  • cessation_referral is the most complex predicate. Translated: currently smoking AND (wants to quit, is unsure, or has tried before).
  • decline_reason uses notEquals: "consent" — so it appears for both decline and ask_doctor, but stays hidden until the patient picks one.

Summary

  • equals matches an exact scalar value. For a multi-select question, it matches if the value is one of the selected options.
  • notEquals matches any value other than the given scalar. Remember: an unanswered question evaluates as false for any atom predicate, including notEquals.
  • in matches against a list of allowed values — the cleaner alternative to chaining several equals rules under anyOf.
  • answered: true gates on the patient having entered any non-empty answer. Empty string, empty array, and null all count as unanswered.
  • allOf is AND across nested rules; anyOf is OR. They nest freely.
  • Predicates may only reference questions earlier in the questions array — forward references are rejected at validation time.
  • The big gotcha: an atom predicate against an unanswered question returns false. To get “show unless explicitly equal to X,” compose with anyOf: [{ notEquals: "X" }, { answered: false }].
  • getVisibleQuestions(questionnaire, answers) and evaluateRule(rule, answers) are exported for tests and preview tooling. embed.goToStep(id) returns false when the target question’s predicate is unmet.
Back to all articles