
You've built Canvas apps before. Maybe you've got a handful of them running in production, serving real users doing real work. And if you've pushed them past a certain level of complexity — multi-screen workflows, role-based UI, shared data across components — you've probably hit the wall. The wall where your app starts behaving unpredictably, where a variable you set on Screen3 mysteriously doesn't reflect on Screen7, where your gallery resets itself when a user navigates back, or where two users report completely different behavior and you can't reproduce either. Welcome to the state management problem.
State management is the discipline of deciding where data lives, how long it lives there, who can change it, and what triggers those changes. In traditional software development, you'd reach for Redux, Vuex, MobX, or a dozen other battle-tested libraries. In Canvas Apps, you're working with a functional, reactive formula engine that has its own opinions about state — and those opinions can absolutely work in your favor once you understand them deeply. But if you treat Power Fx variables like JavaScript variables, you'll write yourself into corners that are genuinely painful to escape.
By the end of this lesson, you'll understand the full state model of Canvas Apps from the ground up: when global variables are appropriate and when they're a code smell, how Named Formulas eliminate entire categories of stale-state bugs, how context variables enable proper component isolation, and how to architect a multi-screen enterprise app so that state flows predictably and debugging is tractable. You'll also understand the performance implications of your choices — because in a 500-user deployment, the difference between a well-architected app and a poorly architected one shows up in real latency numbers.
What you'll learn:
Set(), UpdateContext(), Navigate() parameter passing, and Named FormulasApp.Formulas) replace mutable state for derived data, eliminating entire bug categoriesThis lesson assumes you're already comfortable with Canvas Apps fundamentals. Specifically, you should know:
Patch()Set() and UpdateContext() do at a surface levelIf you've built at least two or three Canvas Apps that have reached production, you're in the right place.
Before you can make good decisions about state management, you need to understand what Power Apps is actually doing when you set a variable. The Power Fx engine is not a traditional imperative runtime. It's a reactive formula engine — meaning the execution model is closer to a spreadsheet than to a JavaScript application. When a value changes, everything that depends on that value is recalculated automatically. This is the platform's fundamental superpower, and it's also the source of most state management confusion.
There are exactly four places state can live in a Canvas App:
1. Global Variables — Created with Set(varName, value). Scoped to the entire app. Any screen, any component, any formula can read or write them. They persist for the lifetime of the app session.
2. Context Variables — Created with UpdateContext({varName: value}) or passed via Navigate(Screen, transition, {varName: value}). Scoped to a single screen. Other screens cannot read or write them directly.
3. Collections — Created with Collect(), ClearCollect(), or Patch(). Essentially in-memory tables that behave like data sources. Scoped globally (like global variables), but hold tabular data.
4. Named Formulas — Defined in App.Formulas. Not variables at all — they're computed expressions that are re-evaluated whenever their dependencies change. Think of them as derived state that's always current.
Most Canvas App developers are fluent with the first three. Named Formulas, introduced more recently, are still underused in production apps — which is a shame, because they're the most powerful tool for eliminating an entire category of state management bugs.
Let's examine the failure modes of each before we talk about solutions.
Global variables feel like the obvious solution for sharing data across screens. You set varCurrentUser on app start, and every screen can read it. Simple, right? The problem isn't global variables per se — it's what happens when you start using them for derived or computed state.
Consider a common enterprise scenario: a project management app where users can belong to multiple teams, have different roles per project, and see different UI based on their current context. A developer might write:
// On App.OnStart
Set(varCurrentUser, LookUp(Users, Email = User().Email));
Set(varCurrentUserRole, LookUp(ProjectRoles, UserID = varCurrentUser.ID && ProjectID = varSelectedProject.ID).Role);
Set(varIsProjectManager, varCurrentUserRole = "Project Manager");
Set(varCanApproveTimesheets, varIsProjectManager || varCurrentUserRole = "Finance Lead");
This looks reasonable. But you've now created a dependency chain between mutable variables — and Power Fx doesn't track dependencies between Set() calls automatically. If varSelectedProject changes (because the user picks a different project), varCurrentUserRole, varIsProjectManager, and varCanApproveTimesheets all become stale. They still hold the values from when you last explicitly called Set().
You'll need to remember — everywhere you update varSelectedProject — to also re-run the whole chain. Miss one place, and you have a subtle authorization bug where a user sees UI they shouldn't see. In a single developer project, this is annoying. In a team of three developers working on a 30-screen app, this is a serious defect risk.
The deeper issue is that you're fighting the reactive model instead of using it. The platform wants to track dependencies and recompute automatically. When you use Set() for derived state, you're opting out of that automation.
Warning: Never use
Set()for values that can be computed from other values. This is the single most common source of stale-state bugs in enterprise Canvas Apps. If a value can be derived, it should be a Named Formula.
Global variables have legitimate uses — they're the right tool for:
varSideNavExpanded = true/false)varSelectedLanguage, varTheme)The mistake is using them as a cache for derived computations.
Named Formulas live in App.Formulas — a property you'll find in the App object in the Tree View, not on any specific screen. They look like this:
// App.Formulas
CurrentUser = LookUp(Users, Email = User().Email);
CurrentUserRole =
If(
IsBlank(SelectedProject),
"None",
LookUp(
ProjectRoles,
UserID = CurrentUser.ID && ProjectID = SelectedProject.ID
).Role
);
IsProjectManager = CurrentUserRole = "Project Manager";
CanApproveTimesheets =
IsProjectManager ||
CurrentUserRole = "Finance Lead" ||
CurrentUser.GlobalRole = "Finance Administrator";
CanEditProjectBudget =
IsProjectManager &&
SelectedProject.Status <> "Archived";
Notice what's different here. These aren't assignments — they're definitions. CurrentUserRole doesn't store a value; it describes what the current user role is, in terms of other named formulas and global variables. Every time SelectedProject changes (a global variable the user sets when picking a project), CurrentUserRole, IsProjectManager, CanApproveTimesheets, and CanEditProjectBudget are all automatically recalculated. No manual re-computation. No stale state.
This is the reactive model working as intended.
There are important constraints on Named Formulas you need to understand:
They cannot contain side effects. You cannot call Set(), Collect(), Patch(), Navigate(), or Notify() inside a Named Formula. This is not a bug — it's a deliberate design choice. Named Formulas are pure functions of their inputs. If you need a side effect, that's a signal that you're trying to express behavior, not state, and behavior belongs in event handlers (button OnSelect, screen OnVisible, etc.).
They are lazily evaluated. Named Formulas are not computed on app start and stored. They're recomputed on demand when something references them. This means a formula that calls LookUp() on a Dataverse table will execute that Dataverse query every time the formula result is needed. For expensive operations, you may need to cache the result in a global variable deliberately, rather than relying on a Named Formula.
They can reference each other. CanApproveTimesheets can reference IsProjectManager which references CurrentUserRole which references CurrentUser and SelectedProject. Power Fx builds the dependency graph automatically and evaluates in the correct order.
They are globally scoped. Like global variables, Named Formulas are accessible from any screen, any component, any formula. They function as a single source of truth for derived state across the entire app.
Here's how the architecture shift looks in practice. In the old, global-variable-heavy approach:
// Button OnSelect: Switch project
Set(varSelectedProject, ThisItem);
Set(varCurrentUserRole, LookUp(ProjectRoles, UserID = varCurrentUser.ID && ProjectID = varSelectedProject.ID).Role);
Set(varIsProjectManager, varCurrentUserRole = "Project Manager");
Set(varCanApproveTimesheets, varIsProjectManager || varCurrentUserRole = "Finance Lead");
In the Named Formula approach:
// App.Formulas
SelectedProject = varSelectedProject; // alias for clarity, optional
// Button OnSelect: Switch project (the entire button formula)
Set(varSelectedProject, ThisItem)
One line. Everything else updates automatically. The Named Formulas do the rest.
Tip: A useful mental model — think of Named Formulas as computed columns in a spreadsheet. When a cell they depend on changes, Excel recalculates them automatically. You don't manually tell the spreadsheet to update column D when you change column B. Named Formulas work exactly the same way.
Context variables (UpdateContext()) are scoped to a single screen. This is their defining characteristic, and it's both their greatest strength and, if misused, their biggest limitation.
The strength: context variables give you genuine encapsulation. If Screen_TimesheetEntry has a localIsEditing context variable, Screen_ProjectDashboard cannot read or write it. Changes on one screen cannot accidentally corrupt the state of another. This is the isolation pattern you want for screen-level state.
The limitation: you cannot pass context variables between screens directly. You can only pass them via the Navigate() function's third parameter:
Navigate(
Screen_TimesheetEntry,
ScreenTransition.None,
{
localTimesheetID: ThisItem.TimesheetID,
localProjectContext: varSelectedProject,
localReadOnly: varCurrentUserRole = "Viewer"
}
)
The receiving screen (Screen_TimesheetEntry) now has those three context variables initialized to the values you passed. They are local to that screen from this point forward.
This pattern is the correct way to parameterize screens, and it has a significant architectural implication: screens become functions. You call them with arguments, and they render based on those arguments. A screen that receives localTimesheetID and localReadOnly will behave differently depending on what you passed. The same screen can handle both view-mode and edit-mode by checking localReadOnly.
Here's a concrete pattern for a screen that handles both creation and editing of a timesheet entry:
// Screen_TimesheetEntry - OnVisible
UpdateContext({
localCurrentTimesheet:
If(
IsBlank(localTimesheetID),
// New timesheet - create a default record
{
TimesheetID: Blank(),
ProjectID: localProjectContext.ID,
EmployeeID: CurrentUser.ID,
WeekEnding: Today(),
Status: "Draft",
TotalHours: 0
},
// Existing timesheet - look it up
LookUp(Timesheets, TimesheetID = localTimesheetID)
),
localHasUnsavedChanges: false,
localValidationErrors: []
});
When localTimesheetID is blank, this is a new record. When it's populated, it's an edit. The screen itself doesn't need to know which case it's in — the OnVisible handler sorts it out.
Warning: A common mistake is using
Screen.OnVisibleto fire data loads that depend on global variables, without also accounting for the case where the screen is navigated to with new context. If a user navigates from Screen_ProjectDashboard to Screen_TimesheetEntry, then uses the back button and navigates again with a different timesheet,OnVisiblefires again. Your state initialization must be idempotent — running it multiple times should produce the same result.
Here's a subtle bug that bites teams regularly. Suppose you have a form screen where a user fills in data. They hit "Cancel" and you navigate back. They navigate back to the same screen for a different record. The context variables from the previous visit persist until OnVisible runs and reinitializes them. If OnVisible has any async behavior (like checking a condition before setting state), there's a window where the old values are visible in the UI.
The fix is a dedicated initialization block at the top of OnVisible that resets all context variables to safe defaults before doing anything else:
// Screen_TimesheetEntry - OnVisible (correct pattern)
// Step 1: Reset to safe defaults immediately
UpdateContext({
localCurrentTimesheet: Blank(),
localHasUnsavedChanges: false,
localValidationErrors: [],
localIsLoading: true
});
// Step 2: Load actual data
UpdateContext({
localCurrentTimesheet:
If(
IsBlank(localTimesheetID),
{TimesheetID: Blank(), ProjectID: localProjectContext.ID, ...},
LookUp(Timesheets, TimesheetID = localTimesheetID)
),
localIsLoading: false
});
The immediate reset ensures users never see stale data from a previous navigation, even for a fraction of a second.
Let's get concrete about how to structure the global state of a complex enterprise app. I'll use a realistic example: a workforce management app used by HR managers and team leads across a 2000-person organization. It has modules for timesheets, leave requests, performance reviews, and headcount planning.
When an app has 40+ global variables, naming becomes critical. A flat namespace like varUser, varProject, varIsLoading, varFilter becomes impossible to reason about. Adopt a namespace convention from day one:
// User context - set on App.OnStart
Set(gUser, {
Profile: LookUp(Employees, AzureADObjectId = User().EntraObjectId),
Permissions: LookUp(UserPermissions, EmployeeID = gUser.Profile.EmployeeID),
Preferences: LookUp(UserPreferences, EmployeeID = gUser.Profile.EmployeeID)
});
// Application state
Set(gApp, {
IsOnline: Connection.Connected,
LoadedAt: Now(),
Version: "2.4.1",
Environment: "Production"
});
// Navigation state
Set(gNav, {
CurrentModule: "Timesheets",
CurrentSection: "MyTimesheets",
BreadcrumbHistory: []
});
// Active selections (things the user has chosen)
Set(gSelected, {
Employee: Blank(),
Project: Blank(),
ReviewPeriod: Blank(),
DateRange: {Start: Date(Year(Today()), Month(Today()), 1), End: Today()}
});
The g prefix signals global scope. The record structure means you update them with Set(gSelected, Patch(gSelected, {Employee: ThisItem})) — preserving all other fields and only updating the one you need.
Tip: Use
Patch()insideSet()to update a single field of a record-typed global variable without overwriting the others:Set(gSelected, Patch(gSelected, {Employee: ThisItem})). This is far safer thanSet(gSelected, {Employee: ThisItem})which would wipe outProject,ReviewPeriod, andDateRange.
With this namespace structure, your Named Formulas become a clean derived-state layer:
// App.Formulas
// User capability checks (derived from gUser)
UserIsHRAdmin =
gUser.Permissions.GlobalRole = "HR Administrator";
UserIsTeamLead =
!IsBlank(gUser.Permissions.TeamLeadForTeamIDs);
UserCanViewAllEmployees =
UserIsHRAdmin || gUser.Permissions.CanViewOrgWide;
UserCanApproveLeave =
UserIsTeamLead || UserIsHRAdmin;
// Scope filter - what employees can this user see?
// This is used as a filter in galleries throughout the app
VisibleEmployeeFilter =
If(
UserCanViewAllEmployees,
Employees,
Filter(
Employees,
TeamID In gUser.Permissions.TeamLeadForTeamIDs ||
EmployeeID = gUser.Profile.EmployeeID
)
);
// Current period display name (derived from gSelected)
SelectedDateRangeLabel =
Text(gSelected.DateRange.Start, "mmm d") &
" – " &
Text(gSelected.DateRange.End, "mmm d, yyyy");
// Active module status
IsTimesheetModuleActive = gNav.CurrentModule = "Timesheets";
IsLeaveModuleActive = gNav.CurrentModule = "LeaveRequests";
IsReviewModuleActive = gNav.CurrentModule = "PerformanceReviews";
Notice that VisibleEmployeeFilter is a Named Formula that returns a table — not just a scalar value. You can use it directly as a gallery's Items property, or compose it further with additional filters. This is an extremely powerful pattern because it centralizes authorization logic in one place. If the access control rules change, you update one Named Formula, and every gallery in the app that uses VisibleEmployeeFilter picks up the change automatically.
One of the trickiest aspects of enterprise Canvas Apps is managing the handoff between screens without creating tight coupling. Here's a pattern that works well: the Navigate-with-Context handoff, combined with global selection state.
The question you need to answer for every inter-screen navigation: "Is this data selection persistent (the user is contextualizing the whole app) or transient (the user is looking at something specific for this screen only)?"
Set(gSelected, Patch(gSelected, {Project: ThisItem})) — global stateNavigate(Screen_TimesheetEntry, None, {localTimesheetID: ThisItem.TimesheetID}) — context parameterThis distinction matters because global selections should survive screen navigation. If a user selects "Q4 2024" as their review period, they expect that filter to apply as they move around the app. That's global state. But the specific record they're editing is screen-local — it doesn't make sense for it to bleed into other screens.
Power Apps components (reusable component libraries) have their own state model, and getting it right is crucial for building truly reusable UI elements.
Components communicate with the rest of the app through two mechanisms: Custom Properties (inputs and outputs) and behavior properties (OnSelect handlers that the host screen can configure). Understanding this boundary is the key to building components that can be dropped into any screen without knowing anything about the host app's global state.
Consider a DateRangePicker component that's supposed to be reusable. If it reads gSelected.DateRange directly, it's no longer reusable — it's tied to this specific app's global state structure. Another app, another screen, a different state shape, and the component breaks.
// BAD: Component directly reads global state
// DateRangePicker component - StartDate label
// Text: Text(gSelected.DateRange.Start, "mmm d, yyyy")
// This couples the component to this specific app's state architecture
The correct approach is to define input properties on the component and pass values from the host screen:
// DateRangePicker component custom properties:
// Input: DefaultStartDate (Date type)
// Input: DefaultEndDate (Date type)
// Input: MinDate (Date type)
// Output: SelectedStartDate (Date type) - exposed via self-referencing formula
// Output: SelectedEndDate (Date type)
// Custom behavior: OnDateRangeChanged (behavior)
// Inside the component: all state is managed via UpdateContext
// The component's OnVisible initializes from input properties:
UpdateContext({
compStartDate: DateRangePicker.DefaultStartDate,
compEndDate: DateRangePicker.DefaultEndDate,
compIsExpanded: false
});
The host screen uses the component like this:
// On the host screen canvas:
// DateRangePicker1 component placement, with custom properties set:
// DefaultStartDate: gSelected.DateRange.Start
// DefaultEndDate: gSelected.DateRange.End
// MinDate: Date(2020, 1, 1)
// DateRangePicker1.OnDateRangeChanged:
Set(gSelected, Patch(gSelected, {
DateRange: {
Start: DateRangePicker1.SelectedStartDate,
End: DateRangePicker1.SelectedEndDate
}
}));
Now the component knows nothing about gSelected. It receives dates, it outputs dates, it fires a behavior when the user changes the selection. The host screen decides what to do with that output. This is the correct inversion of control for Canvas App components.
Tip: Prefix all context variables inside components with a short component identifier (e.g.,
comp,dpfor DatePicker) to avoid collisions if context variable names somehow leak — and to make debugging easier when you're reading component formula bars.
State management choices have direct performance consequences. Let's get specific.
App.OnStart is where most apps initialize global state. The problem is that everything in OnStart is sequential — Power Fx evaluates it in order, and each Set() call that requires a data source lookup blocks until complete. An app with this OnStart:
Set(gUser, LookUp(Employees, AzureADObjectId = User().EntraObjectId));
Set(gUserPermissions, LookUp(UserPermissions, EmployeeID = gUser.Profile.EmployeeID));
Set(gProjects, Filter(Projects, IsActive = true));
Set(gLeaveTypes, LeaveTypes);
Set(gApprovalReasons, ApprovalReasons);
...will execute five sequential data source calls before the app is usable. If each call takes 500ms (reasonable for a Dataverse query over a corporate network), that's 2.5 seconds of blank screen before the user sees anything. On a slow connection, this gets much worse.
The optimization is to use Concurrent() for independent data loads:
Concurrent(
Set(gUser, LookUp(Employees, AzureADObjectId = User().EntraObjectId)),
Set(gLeaveTypes, LeaveTypes),
Set(gApprovalReasons, ApprovalReasons)
);
// gUser must be loaded before this runs:
Concurrent(
Set(gUserPermissions, LookUp(UserPermissions, EmployeeID = gUser.Profile.EmployeeID)),
Set(gProjects, Filter(Projects, IsActive && gUser.Profile.DepartmentID In Projects.AllowedDepartmentIDs))
);
Two Concurrent() blocks instead of five sequential calls. The first block fires three parallel requests. Once that completes and gUser is populated, the second block fires two more parallel requests. Total time: roughly the time of the two slowest calls across the two groups, rather than the sum of all five.
Named Formulas are re-evaluated when their dependencies change, but there's a subtlety: a Named Formula that references a Dataverse table (as opposed to a collection) will issue a query every time it's evaluated. For a formula like:
// This queries Dataverse every time it's evaluated
ProjectBudgetSummary =
GroupBy(
Filter(BudgetLineItems, ProjectID = gSelected.Project.ID),
"Category",
"Items"
);
Every control that references ProjectBudgetSummary will trigger a Dataverse query. If three galleries on one screen all reference it, you've got three queries where you wanted one.
The solution is to cache expensive computations in collections:
// App.Formulas
SelectedProjectID = gSelected.Project.ID; // Lightweight reference
// Screen_ProjectBudget - OnVisible
ClearCollect(
colProjectBudgetCache,
Filter(BudgetLineItems, ProjectID = SelectedProjectID)
);
// App.Formulas (use the collection, not the Dataverse table)
ProjectBudgetSummary = GroupBy(colProjectBudgetCache, "Category", "Items");
ProjectBudgetTotal = Sum(colProjectBudgetCache, Amount);
ProjectBudgetByEmployee = GroupBy(colProjectBudgetCache, "EmployeeID", "Items");
Now the Dataverse query happens once (on screen visit), and ProjectBudgetSummary, ProjectBudgetTotal, and ProjectBudgetByEmployee are all computed from the in-memory collection. Multiple controls referencing these Named Formulas don't trigger additional queries.
Every call to Set() or UpdateContext() is potentially a recalculation event. If 50 controls reference a variable, changing it triggers 50 recalculations. This is usually fine because Power Fx's reactive engine is fast. But it becomes a problem in two scenarios:
High-frequency updates: A text input with a formula on OnChange that calls Set() on every keystroke. If that global variable is referenced by an expensive Named Formula or a large gallery, you'll see noticeable lag as the user types.
Solution: Use Delay() patterns — keep the raw text in a context variable, and only update the global variable (and trigger the search) after a delay or on explicit action:
// SearchInput TextInput - OnChange
UpdateContext({localSearchText: Self.Text})
// SearchButton OnSelect (or use a timer control for true debounce)
Set(gNav, Patch(gNav, {SearchQuery: localSearchText}));
Cascading Set() chains: Updating one variable that triggers OnChange handlers that update other variables that trigger more handlers. This creates a reactive chain that's hard to debug and can cause rendering artifacts.
Solution: Batch your state changes. Instead of five separate Set() calls, do one Set() with a record that contains all your changes:
// Instead of this:
Set(gSelected, Patch(gSelected, {Project: selectedProject}));
Set(gNav, Patch(gNav, {CurrentSection: "ProjectDetail"}));
Set(gApp, Patch(gApp, {LastAction: "ProjectSelected"}));
// Consider setting a single "transaction" variable:
Set(gStateUpdate, {
Selected: Patch(gSelected, {Project: selectedProject}),
Nav: Patch(gNav, {CurrentSection: "ProjectDetail"}),
App: Patch(gApp, {LastAction: "ProjectSelected"})
});
// Then named formulas read gStateUpdate.Selected.Project, etc.
// One write event instead of three
Let me walk through a complete, production-ready pattern for role-based UI state management. This is the kind of thing you'd implement in a real enterprise app with multiple user types.
// App.OnStart - Initialize user context
Set(gUser, {
Profile: LookUp(
Employees,
AzureADObjectId = User().EntraObjectId
),
SessionStart: Now()
});
// App.OnStart (second phase, after gUser loads)
Set(gUser, Patch(gUser, {
Permissions: First(
Filter(
UserPermissions,
EmployeeID = gUser.Profile.EmployeeID
)
),
TeamMemberships: Filter(
TeamMembers,
EmployeeID = gUser.Profile.EmployeeID
)
}));
// App.Formulas - Role definitions
IsSystemAdmin =
gUser.Permissions.SystemRole = "Administrator";
IsHRBP =
gUser.Permissions.SystemRole = "HRBP" || IsSystemAdmin;
IsFinanceAnalyst =
gUser.Permissions.SystemRole = "FinanceAnalyst" || IsSystemAdmin;
IsTeamLead =
!IsEmpty(
Filter(gUser.TeamMemberships, Role = "Lead")
) || IsHRBP;
// What data can this user access?
AccessibleEmployees =
If(
IsHRBP,
// HRBPs see everyone
Employees,
If(
IsTeamLead,
// Team leads see their team + themselves
Filter(
Employees,
TeamID In Filter(gUser.TeamMemberships, Role = "Lead").TeamID ||
EmployeeID = gUser.Profile.EmployeeID
),
// Individual contributors see only themselves
Filter(Employees, EmployeeID = gUser.Profile.EmployeeID)
)
);
// Feature flags derived from role
CanViewCompensation = IsHRBP || IsFinanceAnalyst;
CanExportData =
IsHRBP && gUser.Permissions.DataExportEnabled;
CanManageSystemSettings = IsSystemAdmin;
// Navigation items available to this user
AvailableNavItems =
Filter(
NavigationConfig,
(RequiredRole = "Any") ||
(RequiredRole = "TeamLead" && IsTeamLead) ||
(RequiredRole = "HRBP" && IsHRBP) ||
(RequiredRole = "Finance" && IsFinanceAnalyst) ||
(RequiredRole = "Admin" && IsSystemAdmin)
);
With this architecture, every piece of UI that needs to show or hide based on role simply references the Named Formulas:
Visible: CanViewCompensationVisible: CanExportDataItems: AccessibleEmployeesWhen the app loads and gUser is populated, everything cascades correctly. No manual synchronization. No stale permissions.
Let's put these concepts together. You'll build the state management layer for a Leave Request Management module of an HR app.
Scenario: The app has three user types: Employees (who submit leave requests), Team Leads (who approve them for their team), and HR Admins (who see everything and manage policy). You need state management that correctly handles the scope of what each user sees.
Step 1: Set up the global state structure
In App.OnStart, create the user context. Connect to a Dataverse table called Employees with columns EmployeeID, DisplayName, Email, TeamID, and SystemRole. Also connect to a LeaveRequests table with RequestID, EmployeeID, StartDate, EndDate, LeaveType, Status, and ApproverID.
Write an OnStart formula using Concurrent() that loads:
gUser.ProfilegUser.TeamMembershipscolLeaveTypesStep 2: Create Named Formulas
In App.Formulas, define:
IsEmployee — always true (every user is an employee)IsTeamLead — true if the user has at least one team member reporting to themIsHRAdmin — true if gUser.Profile.SystemRole = "HRAdmin"ScopeFilteredLeaveRequests — a table expression that returns the right leave requests for the current user:PendingApprovalCount — a count of requests that need this user's actionStep 3: Build the navigation screen
Create a Screen_LeaveRequests with a gallery bound to ScopeFilteredLeaveRequests. Add status filter buttons that use UpdateContext() to store a local filter (localStatusFilter) and compose with the already-scoped Named Formula:
// Gallery Items property
Filter(
ScopeFilteredLeaveRequests,
IsBlank(localStatusFilter) || Status = localStatusFilter
)
Step 4: Build the detail screen
Create Screen_LeaveRequestDetail. It should accept {localRequestID: GUID, localReadOnly: Boolean} via Navigate context. On OnVisible, reset local state and load the specific record. Show approval buttons only when:
localReadOnly is falseTest by verifying that navigating to the same screen twice (with different request IDs) correctly resets and reloads state each time.
Step 5: Validate the isolation
Deliberately break it and fix it: temporarily add a Set(gNav, ...) call inside the Screen_LeaveRequestDetail.OnVisible. Notice how this creates a side effect that breaks navigation state. Then refactor to use UpdateContext() instead, demonstrating that the fix is genuinely local.
This almost always means you have a timing issue with OnVisible. You're setting a context variable in OnVisible, but a control's property formula is evaluating before the context variable is initialized. The fix: initialize all context variables to safe, non-blank defaults at the very top of OnVisible, before any data loads.
Canvas Apps don't inherently share state between users — each user's session is independent. If users are seeing each other's data, it's almost certainly a data source filter bug, not a state management bug. Check that your Named Formulas and gallery filters always include EmployeeID = gUser.Profile.EmployeeID or an equivalent user-scope filter, and that the filter is correctly delegable to your data source.
Named Formulas cannot reference each other in a way that creates a cycle. If A = B + 1 and B = A + 1, you have an infinite loop. The fix is to restructure so that information flows in one direction — typically from raw data and user-set variables toward derived values, never backward.
Use Concurrent() for all independent data loads. Use lazy loading — don't load module-specific data in OnStart. Load it when the user navigates to that module (in the screen's OnVisible). Consider showing a loading spinner with gApp.IsInitializing as the Visible flag on a loading overlay.
You cannot call Set(), Collect(), Navigate(), or any behavior function inside App.Formulas. These are pure expression-only formulas. If you're trying to express "when X changes, do Y," that belongs in an event handler, not a Named Formula. Restructure: the Named Formula expresses what something is, and a button's OnSelect or a screen's OnVisible expresses what to do when something happens.
If gSelected is referenced by many controls, a change cascades broadly. This is expected behavior — Power Fx's reactive model. If performance is suffering, audit which controls actually need the value versus which are defensively referencing it "just in case." Remove unnecessary references. Use collections as caches for expensive computations so that cascading reads don't trigger additional queries.
A critical point that's easy to overlook: client-side state management is not a security boundary. Named Formulas, global variables, and context variables are all evaluated in the user's browser. A determined user with browser dev tools can inspect these values. Your authorization logic — which roles can access which data — must be enforced at the data source level (Dataverse row-level security, SharePoint permissions, Power Automate flow authorization checks), not just in Canvas App Named Formulas.
Your Canvas App UI logic (CanViewCompensation, AccessibleEmployees) should be thought of as user experience access control — it prevents legitimate users from seeing data they don't need and keeps the UI clean and appropriate to their role. It is not a substitute for server-side data access controls. If a malicious user bypasses your Canvas App entirely and queries your Dataverse tables directly via the API, your Dataverse row security roles are what protect the data.
Design your apps with this principle: the Canvas App controls what the user sees in the UI. The data source controls what data can actually be accessed. Both layers should enforce the same authorization rules, independently.
Let's consolidate what we've covered.
The core architectural principle: Power Fx is a reactive formula engine, not an imperative runtime. Work with the reactive model, not against it. Use Set() for genuinely mutable user-controlled state. Use Named Formulas for everything that can be derived. Use context variables for screen-local state and screen parameterization.
The Named Formula shift is the biggest single improvement most Canvas App developers can make to their state management. Moving from derived global variables to Named Formulas eliminates the category of stale-state bugs entirely, reduces OnStart complexity, and gives you a single source of truth for authorization logic that cascades automatically.
The Navigate-with-context pattern is how you turn screens into parameterized functions, enabling genuine reuse and avoiding screen-to-screen coupling.
Performance matters at scale. Use Concurrent() for parallel data loads. Cache expensive queries in collections. Understand that Named Formulas referencing remote data sources will re-query on evaluation, so cache first when multiple controls need the same data.
Security is layered. Canvas App state management is a UX concern. Data access control must also exist at the data source level.
The apps that scale — the ones that work reliably for 500 users, that junior developers can maintain, that don't require the original author to explain the "gotchas" in a 90-minute onboarding session — are built on a clear, principled state management architecture. You now have the conceptual foundation to build them.
Learning Path: Canvas Apps 101