Learn how to build a production-ready multi-step wizard in Power Apps Canvas — with proper global state management, a dynamic progress indicator, conditional branching that changes the screen sequence at runtime, and atomic data submission with error handling. This is the pattern that turns overwhelming forms into guided, trustworthy experiences.

Imagine you're building an employee onboarding app. The HR team needs to collect personal details, emergency contacts, benefits elections, equipment preferences, and IT setup requirements — roughly 40 fields across five logical categories. If you dump all of that onto a single screen, you get a form that looks like a tax return. Users get overwhelmed, make mistakes, skip sections, and abandon the process entirely. You end up with incomplete data and a help desk backlog of "how do I fill this out?" tickets.
The multi-step wizard pattern solves this by breaking complex data entry into a guided, sequential flow. Each screen collects one logical chunk of information, validates it before allowing progression, and gives the user a clear sense of where they are and how much is left. Done well, a wizard feels like a conversation rather than an interrogation. Done poorly — with broken back-navigation, lost data, and unhelpful error messages — it's worse than the wall of fields you started with.
By the end of this lesson you'll know how to architect a multi-step wizard in Power Apps Canvas that is genuinely production-ready: properly managing state across screens, building a reusable progress indicator component, implementing conditional branching that changes the screen sequence based on user input, and committing data to your data source only on final submission. You'll also learn where the common architectural mistakes are made and how to avoid them.
What you'll learn:
Patch on the final screen, with proper error handlingThis lesson assumes you're comfortable with the fundamentals of Canvas App development. Specifically, you should already know:
Patch, Navigate, and Filter in formulas (covered in Master Power Apps Formulas: Navigate, Filter, Lookup, and Patch for Professional Apps)IsBlank and IsMatch (see Power Apps Data Validation: Using If, IsBlank, and IsMatch to Prevent Bad Data in Forms)If any of those feel shaky, build that foundation first. The wizard pattern layers on top of all of them simultaneously.
Here's the mistake most developers make with wizards: they start building screens immediately, binding controls to context variables, and then realize on step 4 that screen-scoped context variables are invisible from other screens. By step 5 they're duct-taping global variables everywhere, and by deployment they have 30 gvar variables with no coherent naming scheme and a debugging nightmare when something breaks.
The right starting point is a deliberate state architecture decision. You have three options, and they have meaningfully different trade-offs.
You maintain one global variable — call it gWizardData — as a record that holds all in-progress wizard fields:
// On App.OnStart
Set(gWizardData, {
// Step 1: Personal Details
FirstName: "",
LastName: "",
PersonalEmail: "",
StartDate: Today(),
// Step 2: Employment Type
EmploymentType: "", // "FTE" or "Contractor"
// Step 3a: FTE Benefits (only shown if EmploymentType = "FTE")
HealthPlan: "",
RetirementContribution: 0,
// Step 3b: Contractor Details (only shown if EmploymentType = "Contractor")
AgencyName: "",
ContractEndDate: Blank(),
// Step 4: Equipment
LaptopModel: "",
RequestMonitor: false,
// Step 5: IT Access
VPNRequired: false,
PrimarySystem: ""
})
Each screen's controls bind to this record for their default values, and each Next button updates the record before navigating:
// Step 1 Next button - OnSelect
Set(gWizardData, Patch(gWizardData, {
FirstName: txtFirstName.Text,
LastName: txtLastName.Text,
PersonalEmail: txtEmail.Text,
StartDate: datePicker1.SelectedDate
}));
Navigate(scrStep2_EmploymentType)
Pros: Simple mental model. All data is in one place. Easy to inspect in the Monitor tool. The final Patch to your data source is a single clean formula.
Cons: The record grows large and complex. If a user restarts mid-wizard, you need to reset the entire record manually. No natural way to track which steps are "complete."
You maintain a collection called colWizardSteps where each record represents a step, its completion status, and whether it's currently visible (relevant for branching):
// On App.OnStart
ClearCollect(colWizardSteps,
{StepID: 1, StepName: "Personal Details", IsComplete: false, IsVisible: true},
{StepID: 2, StepName: "Employment Type", IsComplete: false, IsVisible: true},
{StepID: 3, StepName: "Benefits", IsComplete: false, IsVisible: false}, // shown only for FTE
{StepID: 4, StepName: "Contractor Info", IsComplete: false, IsVisible: false}, // shown only for Contractor
{StepID: 5, StepName: "Equipment", IsComplete: false, IsVisible: true},
{StepID: 6, StepName: "IT Access", IsComplete: false, IsVisible: true}
)
This collection drives the progress indicator directly. When the user selects "FTE" on step 2, you update the collection:
Patch(colWizardSteps,
LookUp(colWizardSteps, StepID = 3), {IsVisible: true}
);
Patch(colWizardSteps,
LookUp(colWizardSteps, StepID = 4), {IsVisible: false}
)
Pros: The step metadata and form data are separated cleanly. The progress indicator formula is dead simple — just filter for visible steps. Completion tracking is built in.
Cons: More moving parts. You still need a separate gWizardData record (or individual globals) for the actual form values.
Each screen manages its own context variable for local input state, and when the user clicks Next, the values are merged into a collection that acts as the data layer. This is the most complex but also the most robust for apps where screens might be reused or rendered conditionally.
For most real-world wizards, the right answer is a hybrid of Options 1 and 2: a single gWizardData record for form values, plus a colWizardSteps collection for step metadata. This is what we'll build throughout the rest of this lesson.
Key insight
The fundamental rule is that your in-progress wizard data must live in global scope — either a global variable or a collection. Context variables are screen-scoped and will vanish the moment the user navigates away. Any field stored in a context variable will be blank when the user hits Back and returns to that screen.
Create a new Canvas App from blank and set it to tablet layout (1366×768). Create the following screens:
scrWizard_Start — landing/intro screenscrStep1_PersonalDetailsscrStep2_EmploymentTypescrStep3A_FTEBenefitsscrStep3B_ContractorDetailsscrStep4_EquipmentscrStep5_ITAccessscrWizard_ConfirmationIn App.OnStart, initialize all your state:
// Initialize wizard form data
Set(gWizardData, {
FirstName: "",
LastName: "",
PersonalEmail: "",
StartDate: Today(),
EmploymentType: "",
HealthPlan: "",
RetirementContribution: 0,
AgencyName: "",
ContractEndDate: Blank(),
LaptopModel: "MacBook Pro 14",
RequestMonitor: false,
VPNRequired: false,
PrimarySystem: ""
});
// Initialize step metadata
ClearCollect(colWizardSteps,
{StepID: 1, StepName: "Personal Details", IsComplete: false, IsVisible: true, ScreenName: "scrStep1_PersonalDetails"},
{StepID: 2, StepName: "Employment Type", IsComplete: false, IsVisible: true, ScreenName: "scrStep2_EmploymentType"},
{StepID: 3, StepName: "Benefits", IsComplete: false, IsVisible: false, ScreenName: "scrStep3A_FTEBenefits"},
{StepID: 4, StepName: "Contractor Info", IsComplete: false, IsVisible: false, ScreenName: "scrStep3B_ContractorDetails"},
{StepID: 5, StepName: "Equipment", IsComplete: false, IsVisible: true, ScreenName: "scrStep4_Equipment"},
{StepID: 6, StepName: "IT Access", IsComplete: false, IsVisible: true, ScreenName: "scrStep5_ITAccess"}
);
// Track current step
Set(gCurrentStepID, 1);
// Track validation error state
Set(gStepHasError, false)
Also create a named formula (available in modern Power Apps) or a global flag to compute the current step's ordinal position among visible steps — you'll need this for the progress indicator:
// gCurrentStepOrdinal: position of current step among visible steps only
Set(gCurrentStepOrdinal,
CountRows(Filter(colWizardSteps, IsVisible = true And StepID <= gCurrentStepID))
);
// gTotalVisibleSteps: total visible step count (changes with branching)
Set(gTotalVisibleSteps,
CountRows(Filter(colWizardSteps, IsVisible = true))
)
Note
You'll need to call the two Set statements above that compute ordinal and total any time you update colWizardSteps or navigate between steps. This is a good candidate for a named helper formula if your environment supports them. See Canvas App State Management at Scale: Global Variables, Named Formulas, and Context Isolation for Enterprise Apps for the Named Formulas approach.
The progress indicator is the visual element that tells users "you're on step 3 of 5." Before building it on each screen individually, let's build it as a reusable component — this is exactly the scenario Power Apps Components were designed for. See Power Apps Components: Build Reusable UI Elements for Enterprise Scale for a full component deep-dive; here we'll focus on what this specific component needs.
Create a component called cmp_WizardProgress with these custom properties:
| Property | Type | Description |
|---|---|---|
CurrentStep |
Number | The ordinal position of the current step (1-based) |
TotalSteps |
Number | Total number of visible steps |
StepData |
Table | The filtered visible steps from colWizardSteps |
BarColor |
Color | Accent color for completed segments |
Inside the component, add a horizontal Gallery control called galProgressDots and set its Items property to cmp_WizardProgress.StepData — that is, the filtered collection of visible steps passed in from the parent screen.
Each item in the gallery gets:
Fill property is:If(ThisItem.StepID <= LookUp(cmp_WizardProgress.StepData,
CountRows(Filter(cmp_WizardProgress.StepData, StepID <= ThisItem.StepID))
= cmp_WizardProgress.CurrentStep).StepID,
cmp_WizardProgress.BarColor, // completed or current
RGBA(200, 200, 200, 1) // future
)
Actually, there's a cleaner way to think about this. Because StepData only contains visible steps in order, and you're passing in CurrentStep as the ordinal (1-based position), you can simplify dramatically:
// Circle Fill in the gallery
If(ThisItem.StepID_Ordinal <= cmp_WizardProgress.CurrentStep,
cmp_WizardProgress.BarColor,
RGBA(220, 220, 220, 1)
)
For this to work, add an StepID_Ordinal column to your collection when you filter it. Update your navigation helper to pass this computed table:
// Computed visible steps with ordinal positions, used by the progress component
ClearCollect(colVisibleSteps,
AddColumns(
Filter(colWizardSteps, IsVisible = true),
"StepID_Ordinal",
CountRows(Filter(Filter(colWizardSteps, IsVisible = true), StepID <= ThisRecord.StepID))
)
)
Call this ClearCollect on App.OnStart and whenever you modify step visibility.
Between each dot, add a thin horizontal rectangle whose Width is proportional to whether the step to the left is complete:
// Connector rectangle Fill
If(ThisItem.StepID_Ordinal < cmp_WizardProgress.CurrentStep,
cmp_WizardProgress.BarColor,
RGBA(220, 220, 220, 1)
)
Outside the gallery, add a label showing "Step X of Y":
"Step " & cmp_WizardProgress.CurrentStep & " of " & cmp_WizardProgress.TotalSteps
And a second label showing the current step name:
LookUp(cmp_WizardProgress.StepData, StepID_Ordinal = cmp_WizardProgress.CurrentStep).StepName
Place this component at the top of every wizard screen. Each screen passes in the same colVisibleSteps collection, the current step ordinal for that screen (hardcoded per screen since screens are static), and the app's accent color.
Tip
Instead of hardcoding the step ordinal per screen, derive it from the global gCurrentStepOrdinal variable you set during navigation. That way, if your branching logic changes which step a screen corresponds to, the progress indicator stays accurate without updating every screen individually.
Here is where many wizard implementations cut corners and cause production problems. The temptation is to disable the Next button until all fields are filled, which is better than allowing blank submissions but still inadequate. Users don't understand why the button is disabled, and format errors (a phone number with letters, an email without @, a date in the past when only future dates are valid) slip through "is not blank" checks.
The right approach is a validation function that runs on Next button press, sets an error flag, and displays meaningful inline error messages — not a toast that disappears in 3 seconds.
Add a hidden label for each field that contains the error message:
For the email field, add a label called lblEmailError with:
Visible property: gStepHasError And Not(IsMatch(txtEmail.Text, Match.Email))Text property: "Please enter a valid email address"Color property: RGBA(196, 43, 28, 1) (red)Then wire the Next button's OnSelect:
// Step 1 Next Button - OnSelect
If(
IsBlank(txtFirstName.Text) Or
IsBlank(txtLastName.Text) Or
Not(IsMatch(txtEmail.Text, Match.Email)) Or
datePicker1.SelectedDate < Today(),
// Validation failed
Set(gStepHasError, true),
// Validation passed
Set(gStepHasError, false);
Set(gWizardData, Patch(gWizardData, {
FirstName: txtFirstName.Text,
LastName: txtLastName.Text,
PersonalEmail: txtEmail.Text,
StartDate: datePicker1.SelectedDate
}));
Patch(colWizardSteps,
LookUp(colWizardSteps, StepID = 1),
{IsComplete: true}
);
Set(gCurrentStepID, 2);
Set(gCurrentStepOrdinal, 2);
Navigate(scrStep2_EmploymentType, ScreenTransition.Cover)
)
Notice the sequence: validate first, fail loudly if invalid, only write to gWizardData and navigate when everything passes. The gStepHasError flag persisting as true means every error label that depends on it will stay visible until the user corrects the field and presses Next again. This is intentional — unlike transient toasts, the error stays in view while the user edits.
Warning
Do not use Notify() as your only validation feedback mechanism in a wizard. Notify messages disappear after a few seconds, and users on mobile often miss them entirely. Use persistent inline error labels positioned directly under each problematic field. Reserve Notify for confirmation messages after successful actions.
This is the genuinely hard part of wizard design. When a user selects "Contractor" on step 2, you don't want to show the FTE benefits screen (step 3A). When they select "FTE," you don't want the contractor details screen (step 3B). The branching decision must:
colWizardStepscolVisibleSteps with correct ordinalsThis screen has two toggle buttons or a radio control bound to gWizardData.EmploymentType. The Next button does the heavy lifting:
// Step 2 Next Button - OnSelect
If(
IsBlank(rdoEmploymentType.Selected.Value),
Set(gStepHasError, true),
// Write employment type to wizard data
Set(gStepHasError, false);
Set(gWizardData, Patch(gWizardData, {
EmploymentType: rdoEmploymentType.Selected.Value
}));
// --- BRANCHING LOGIC ---
If(rdoEmploymentType.Selected.Value = "FTE",
// Show FTE benefits, hide contractor details
Patch(colWizardSteps, LookUp(colWizardSteps, StepID = 3), {IsVisible: true});
Patch(colWizardSteps, LookUp(colWizardSteps, StepID = 4), {IsVisible: false});
// Clear any previously entered contractor data
Set(gWizardData, Patch(gWizardData, {
AgencyName: "",
ContractEndDate: Blank()
})),
// EmploymentType = "Contractor"
Patch(colWizardSteps, LookUp(colWizardSteps, StepID = 3), {IsVisible: false});
Patch(colWizardSteps, LookUp(colWizardSteps, StepID = 4), {IsVisible: true});
// Clear any previously entered FTE data
Set(gWizardData, Patch(gWizardData, {
HealthPlan: "",
RetirementContribution: 0
}))
);
// Recompute visible steps with ordinals
ClearCollect(colVisibleSteps,
AddColumns(
Filter(colWizardSteps, IsVisible = true),
"StepID_Ordinal",
CountRows(Filter(Filter(colWizardSteps, IsVisible = true), StepID <= ThisRecord.StepID))
)
);
// Update step metadata and navigate
Patch(colWizardSteps, LookUp(colWizardSteps, StepID = 2), {IsComplete: true});
Set(gCurrentStepID, If(rdoEmploymentType.Selected.Value = "FTE", 3, 4));
Set(gCurrentStepOrdinal, 3); // Always step 3 of N regardless of branch
// Navigate to correct branch screen
If(rdoEmploymentType.Selected.Value = "FTE",
Navigate(scrStep3A_FTEBenefits, ScreenTransition.Cover),
Navigate(scrStep3B_ContractorDetails, ScreenTransition.Cover)
)
)
The step ordinal is 3 regardless of which branch fires, because both branch screens are the third step in the visible sequence. The colVisibleSteps recomputation handles this correctly — after hiding one branch screen and showing the other, the ordinals recompute based on the updated visible set.
Key insight
The StepID values in colWizardSteps are fixed identifiers — they never change. The StepID_Ordinal values in colVisibleSteps are computed positions in the visible sequence — they change whenever visibility changes. Never confuse the two. Your progress indicator uses ordinals; your data lookup uses StepIDs.
Back navigation in branching wizards is trickier than it looks. A naive Back button that calls Back() works fine in linear wizards but can break in branching ones if the Power Apps navigation stack doesn't reflect your intent.
Instead of relying on Back(), implement explicit back navigation based on gCurrentStepID:
// Back Button - OnSelect (placed on a reusable header component or per-screen)
Switch(gCurrentStepID,
1, Navigate(scrWizard_Start),
2, Set(gCurrentStepID, 1); Set(gCurrentStepOrdinal, 1); Navigate(scrStep1_PersonalDetails, ScreenTransition.UnCover),
3, Set(gCurrentStepID, 2); Set(gCurrentStepOrdinal, 2); Navigate(scrStep2_EmploymentType, ScreenTransition.UnCover),
4, Set(gCurrentStepID, 2); Set(gCurrentStepOrdinal, 2); Navigate(scrStep2_EmploymentType, ScreenTransition.UnCover),
5, If(gWizardData.EmploymentType = "FTE",
Set(gCurrentStepID, 3); Navigate(scrStep3A_FTEBenefits, ScreenTransition.UnCover),
Set(gCurrentStepID, 4); Navigate(scrStep3B_ContractorDetails, ScreenTransition.UnCover)
);
Set(gCurrentStepOrdinal, 3),
6, Set(gCurrentStepID, 5); Set(gCurrentStepOrdinal, 4); Navigate(scrStep4_Equipment, ScreenTransition.UnCover)
)
Both branch screens (StepID 3 and 4) navigate back to step 2. The equipment screen (StepID 5) navigates back to whichever branch screen was active, determined by gWizardData.EmploymentType. This keeps the navigation semantically correct regardless of how the user got to the current screen.
When a user navigates back to step 1 and then forward again, their previously entered values should still be there. This works automatically as long as your controls' Default properties point to gWizardData:
// txtFirstName.Default
gWizardData.FirstName
// txtEmail.Default
gWizardData.PersonalEmail
// datePicker1.DefaultDate
gWizardData.StartDate
Because gWizardData persists in global scope and you wrote the values on Next, they're still there when the screen reinitializes. This is a key advantage of the global record approach — you get free "remember my answers" behavior without any extra logic.
Tip
Always set the Default property of every wizard input control to the corresponding field in gWizardData. This gives you back-navigation restoration, resume capability, and edit mode (pre-loading an existing record for update) essentially for free. The pattern is identical for all three use cases.
The last screen of your wizard is a summary and confirmation screen. It should do three things: show all entered data for review, provide a final submit action, and handle submission errors gracefully.
Use labels bound directly to gWizardData fields to display a read-only summary. Group them visually by section using containers. For example:
// Summary label - Full Name
gWizardData.FirstName & " " & gWizardData.LastName
// Summary label - Benefits
If(gWizardData.EmploymentType = "FTE",
"Health Plan: " & gWizardData.HealthPlan & Char(10) &
"401(k) Contribution: " & Text(gWizardData.RetirementContribution, "[$-en-US]#,##0") & "%",
"Agency: " & gWizardData.AgencyName & Char(10) &
"Contract End: " & Text(gWizardData.ContractEndDate, "mmmm d, yyyy")
)
This is where all the data hits your data source for the first time. The entire wizard collects data into memory and only writes to SharePoint (or Dataverse) on this single button press. This is intentional — it means incomplete submissions don't create partial records, and the user can abandon the wizard at any point without leaving garbage data.
// Submit Button - OnSelect
Set(gIsSubmitting, true);
IfError(
// Attempt to Patch the record to SharePoint
Patch('Employee Onboarding',
Defaults('Employee Onboarding'),
{
Title: gWizardData.FirstName & " " & gWizardData.LastName,
FirstName: gWizardData.FirstName,
LastName: gWizardData.LastName,
PersonalEmail: gWizardData.PersonalEmail,
StartDate: gWizardData.StartDate,
EmploymentType: gWizardData.EmploymentType,
HealthPlan: If(gWizardData.EmploymentType = "FTE", gWizardData.HealthPlan, Blank()),
RetirementContribution: If(gWizardData.EmploymentType = "FTE", gWizardData.RetirementContribution, Blank()),
AgencyName: If(gWizardData.EmploymentType = "Contractor", gWizardData.AgencyName, Blank()),
ContractEndDate: If(gWizardData.EmploymentType = "Contractor", gWizardData.ContractEndDate, Blank()),
LaptopModel: gWizardData.LaptopModel,
RequestMonitor: gWizardData.RequestMonitor,
VPNRequired: gWizardData.VPNRequired,
PrimarySystem: gWizardData.PrimarySystem
}
);
// Success path
Set(gIsSubmitting, false);
Navigate(scrWizard_Confirmation, ScreenTransition.Fade),
// Error path - stay on screen, show error
Set(gIsSubmitting, false);
Set(gSubmitError, "Submission failed. Please try again or contact IT support. Error: " & FirstError.Message)
)
Notice the IfError wrapper. Without it, if the SharePoint connector times out or returns an error, the user gets no feedback and the confirmation screen never appears. See Canvas App Error Handling: Building Resilient Apps with IfError, Notify, and Graceful Failure Patterns for a full treatment of this pattern.
The gIsSubmitting flag drives a loading spinner overlay and disables the Submit button to prevent double-submission:
// Submit button Disabled property
gIsSubmitting
// Loading overlay Visible property
gIsSubmitting
// Error label Visible property
Not(IsBlank(gSubmitError))
// Error label Text property
gSubmitError
A well-architected wizard should handle both new record creation and editing an existing record without forking the entire codebase. The key is an initialization pattern that pre-loads an existing record into gWizardData when the wizard is launched in "edit mode."
Add a parameter to the app's App.OnStart (or the start screen's OnVisible) that checks whether a record ID was passed:
// scrWizard_Start - OnVisible
If(Not(IsBlank(Param("RecordID"))),
// Edit mode: load existing record
Set(gEditMode, true);
Set(gExistingRecord, LookUp('Employee Onboarding', ID = Value(Param("RecordID"))));
Set(gWizardData, {
FirstName: gExistingRecord.FirstName,
LastName: gExistingRecord.LastName,
PersonalEmail: gExistingRecord.PersonalEmail,
StartDate: gExistingRecord.StartDate,
EmploymentType: gExistingRecord.EmploymentType,
HealthPlan: gExistingRecord.HealthPlan,
RetirementContribution: gExistingRecord.RetirementContribution,
AgencyName: gExistingRecord.AgencyName,
ContractEndDate: gExistingRecord.ContractEndDate,
LaptopModel: gExistingRecord.LaptopModel,
RequestMonitor: gExistingRecord.RequestMonitor,
VPNRequired: gExistingRecord.VPNRequired,
PrimarySystem: gExistingRecord.PrimarySystem
});
// Set up branch visibility based on existing EmploymentType
If(gExistingRecord.EmploymentType = "FTE",
Patch(colWizardSteps, LookUp(colWizardSteps, StepID = 3), {IsVisible: true});
Patch(colWizardSteps, LookUp(colWizardSteps, StepID = 4), {IsVisible: false}),
Patch(colWizardSteps, LookUp(colWizardSteps, StepID = 3), {IsVisible: false});
Patch(colWizardSteps, LookUp(colWizardSteps, StepID = 4), {IsVisible: true})
),
// New record mode
Set(gEditMode, false);
Set(gExistingRecord, Blank())
)
The Submit button on the confirmation screen then uses gEditMode to decide whether to Patch an existing record or create a new one:
Patch('Employee Onboarding',
If(gEditMode, gExistingRecord, Defaults('Employee Onboarding')),
{ ... all fields ... }
)
That single If in the first argument to Patch is the entire difference between insert and update. Because all your controls bind to gWizardData using their Default property, edit mode is fully pre-populated without any additional control-level logic. This is an elegant pattern that keeps both modes in a single codebase.
Note
When connecting to Dataverse as your data source, the pattern for distinguishing insert vs update is slightly different — you'll use Patch with the record's GUID as the key. The concept is identical, but the syntax for Defaults() versus a record reference differs. See Connecting Power Apps to SharePoint, Excel, and Dataverse: A Complete Integration Guide for data-source-specific patterns.
The wizard pattern as described is fast for individual user sessions. But there are several places where performance can degrade at scale.
Initializing gWizardData, colWizardSteps, and colVisibleSteps on App.OnStart is cheap — these are small, static operations with no network calls. Keep it this way. The temptation is to also load lookup data (dropdown options, user profile information, manager lists) in OnStart, but each connector call adds latency. Load reference data lazily on the first screen that needs it, and cache it in a global collection.
For example, the health plan options dropdown on step 3A should load from SharePoint only when the user reaches that screen:
// scrStep3A_FTEBenefits - OnVisible
If(IsEmpty(colHealthPlans),
ClearCollect(colHealthPlans, 'Health Plan Options')
)
The If(IsEmpty(...)) guard prevents reloading on every back-navigation visit. See Power Apps Performance Optimization: Delegation, Data Row Limits, and Reducing App Load Times for deeper treatment of load-time optimization.
The ClearCollect on colVisibleSteps that you call after every branch decision is a local in-memory operation — it's fast regardless of collection size. But the AddColumns with CountRows(Filter(...)) inside it is O(n²) in theory (for each row, it filters the whole collection). For a wizard with 10–15 steps, this is utterly irrelevant. For a wizard with 100 steps (which you should never build, but sometimes inherit), you'd want to precompute this differently.
The LookUp that loads the existing record for edit mode — LookUp('Employee Onboarding', ID = Value(Param("RecordID"))) — is fully delegable to both SharePoint and Dataverse when filtering on the primary key. This is safe at any record count. Where you can get into trouble is if you try to look up by a non-indexed column; always filter on ID or a properly indexed column.
Build the first three screens of an Employee Onboarding wizard using the patterns from this lesson. Specifically:
Step 1 — Personal Details:
Match.Email), Start Date (required, must be >= Today())gWizardData, marks step complete, and navigatesStep 2 — Employment Type:
Step 3A — FTE Benefits (shown when EmploymentType = "FTE"):
Progress Indicator (bonus):
Test these scenarios:
Symptom: Data entered on step 1 is blank when you read it from step 3.
Cause: You stored step 1 values in a context variable using UpdateContext(). Context variables are screen-scoped and are reset when you navigate away.
Fix: Use Set() with global variables or write to a collection. The gWizardData global record pattern eliminates this class of bug entirely.
Symptom: Partial records with blank fields appear in your SharePoint list when users abandon the wizard midway.
Cause: Each Next button's formula patches the data source directly instead of writing to in-memory state.
Fix: All Next buttons write exclusively to gWizardData and colWizardSteps. The data source is touched exactly once: on the final Submit button.
Symptom: Pressing Back from the equipment screen takes you to whichever branch screen was last in the navigation stack, which may be the wrong branch if the user changed their employment type.
Cause: Power Apps' navigation stack records every Navigate() call. If the user went to FTE benefits, went back, changed to Contractor, went forward, and then went back from equipment — the stack has FTE benefits in it, not contractor details.
Fix: Never rely on Back() in branching wizards. Implement explicit back navigation using a Switch on gCurrentStepID, routing to the semantically correct previous screen based on current state.
Symptom: The indicator shows "Step 3 of 5" when the user selected a branch that should only have 4 visible steps.
Cause: You forgot to call ClearCollect(colVisibleSteps, ...) after updating step visibility in colWizardSteps. The progress component is reading stale ordinal data.
Fix: Every place where you call Patch(colWizardSteps, ...) to change IsVisible, immediately follow it with the ClearCollect(colVisibleSteps, AddColumns(...)) recomputation. Consider encapsulating this in a named formula or a reusable component behavior.
Symptom: Two records appear in SharePoint for a single wizard completion.
Cause: The user clicked Submit, the network was slow, the button didn't visibly disable fast enough, and they clicked again.
Fix: Set the Submit button's DisplayMode to If(gIsSubmitting, DisplayMode.Disabled, DisplayMode.Edit) and set gIsSubmitting = true as the very first action in the button's OnSelect. Show a visible loading state (a spinner overlay or progress text) so users know the submission is in progress.
Warning
Do not rely solely on gIsSubmitting to prevent double-submission if your app might be used in Teams or embedded in SharePoint — the control rendering pipeline in those hosts can sometimes call OnSelect twice on a single tap due to focus events. Add a Timer control or check for an existing record before submitting as a secondary guard. See Debugging Canvas Apps: Using the Power Apps Monitor Tool and Formula Errors to Fix Issues Fast for how to diagnose double-trigger issues with the Monitor tool.
Symptom: A user leaves step 1 with an error showing, navigates to step 2 (somehow), comes back to step 1, and the error label is still visible even though the field now has a valid value.
Cause: gStepHasError is still true from the previous failed validation attempt, and the error labels' Visible logic is gStepHasError And <field-condition>. When the field is now valid, <field-condition> is false, so the label is hidden correctly. But if you have a general error label not tied to a specific field condition — just gStepHasError — it stays visible.
Fix: Reset gStepHasError to false in each screen's OnVisible:
// Each wizard screen - OnVisible
Set(gStepHasError, false)
This ensures the error display is always fresh when a screen becomes active, regardless of what state the user left it in.
The multi-step wizard pattern is one of the highest-value Canvas App patterns you'll deploy. It directly improves data quality by enforcing field-by-field validation, reduces user cognitive load by presenting one logical section at a time, and enables complex conditional flows that static forms can't express.
Here's what you built in this lesson:
gWizardData for form values and colWizardSteps for step metadata — keeping these concerns clearly separatedcolVisibleSteps, which correctly reflects the current position even when branching changes the total step countSwitch on gCurrentStepID rather than the native Back() function, so users always land on the semantically correct previous screenIfError handling and a double-submit guardThe natural next layer of sophistication is building the progress indicator as a proper Power Apps Component so it can be dropped onto new wizard apps without rebuilding it. That's covered in Power Apps Components: Build Reusable UI Elements for Enterprise Scale.
If your wizard collects photos, attachments, or scanned barcodes at any step, you'll need to handle those control types specifically — the data model and submission logic differ from text fields. See Power Apps Camera and Attachment Controls: Adding Photo Capture and File Uploads to Canvas App Forms for the full pattern.
Finally, if your wizard will be deployed to a large organization, consider role-based variations where certain steps are shown or hidden based on the user's department or role. That capability combines cleanly with the conditional branching pattern you learned here — the mechanism is identical, just driven by Azure AD group membership rather than user input. See Implementing Role-Based Screen Access and Dynamic UI in Canvas Apps Using Azure AD Group Membership.