Learn how to architect a scalable conditional formatting system in Power Apps Canvas Apps. This deep-dive lesson covers dynamic color, visibility, and DisplayMode based on both data state and user role — using named formulas, style maps, and capability-based logic that actually holds up in enterprise apps.

Picture this: your organization has just deployed a procurement approval app to 400 employees across five departments. Approvers need to see a "Reject" button in red with a confirmation dialog. Requesters should never see that button at all. Finance reviewers need the budget field to be read-only unless the purchase order is in "Draft" status. And when a record is overdue, every row in the gallery should glow amber — unless it's already been escalated, in which case it turns red. On top of all that, a new compliance requirement means certain fields must be masked for users outside the Finance security group.
If your gut response is "I'll just add a bunch of If statements everywhere and manually tweak colors," you're not wrong — but you're about to create a maintenance nightmare. In a 40-screen enterprise app, ad-hoc conditional formatting turns into spaghetti faster than you can say "delegation warning." The alternative is a systematic, architectural approach to dynamic styling that scales gracefully, stays testable, and won't collapse when your business logic changes next quarter.
By the end of this lesson, you'll be able to design and implement conditional formatting systems that handle color, visibility, and DisplayMode based on both data state and user role — without repeating yourself, without tangling your logic, and without sacrificing performance.
What you'll learn:
DisplayMode dynamically across galleries, forms, and standalone controlsThis lesson assumes you're comfortable building multi-screen Canvas Apps and writing intermediate Power Fx formulas. You should understand how galleries and forms work at an architectural level — if you need a refresher, the lesson on Power Apps Controls: Galleries, Forms, and Data Tables - Advanced Architecture and Performance covers that ground thoroughly. You should also be familiar with variables and collections; the article on Power Apps Variables Explained: When to Use Global Variables, Context Variables, and Collections in Canvas Apps is a strong companion read.
Before we write a single formula, let's understand why most Canvas Apps accumulate a formatting debt.
The typical pattern goes like this: a developer adds a label and sets its Color property to:
If(ThisItem.Status = "Overdue", Color.Red, Color.Black)
Simple, readable, done. Then a second condition gets added:
If(ThisItem.Status = "Overdue", Color.Red,
ThisItem.Status = "Escalated", Color.DarkOrange,
Color.Black)
Then someone asks for role-based coloring. Then a special case for VIP vendors. By the third iteration, that Color property is a 15-line formula. And there are 8 other controls on the same gallery template with similar logic. Now multiply that across 40 screens.
The core architectural flaw is that business logic and presentation logic are fused at the control level. When the business rules change — and they always change — you're hunting through every property of every control to update them.
The solution is a three-layer approach:
"overdue", "pending_approval", "locked")If chainsThis pattern separates concerns cleanly and gives you a single place to update when requirements change.
The first thing to establish is a consistent vocabulary of states. Rather than scattering raw comparisons like ThisItem.Status = "Overdue" throughout your app, you define what "overdue" means once.
Power Fx named formulas (declared in the App.Formulas property) are the ideal home for state resolution logic. They're lazy-evaluated, don't consume a variable slot, and are available app-wide. If you haven't worked with them deeply yet, the Canvas App State Management at Scale: Global Variables, Named Formulas, and Context Isolation for Enterprise Apps article covers the internals.
Here's a realistic named formula for a procurement app:
// App.Formulas
POStateToken(poRecord) =
If(
poRecord.SubmittedDate < Today() - 14 && poRecord.ApprovalStatus = "Pending",
"overdue",
poRecord.ApprovalStatus = "Rejected",
"rejected",
poRecord.ApprovalStatus = "Approved" && IsBlank(poRecord.PO_Number),
"pending_po_generation",
poRecord.ApprovalStatus = "Approved",
"approved",
poRecord.ApprovalStatus = "Draft",
"draft",
"unknown"
);
Now anywhere in your app, instead of writing the business rule inline, you write POStateToken(ThisItem) or POStateToken(galPurchaseOrders.Selected). The logic lives in one place.
Key insight
Named formulas in Power Apps are recalculated automatically when their dependencies change — they're not cached stale values. This means POStateToken will always reflect the current record state without you needing to manually refresh it. The performance cost is paid only when the formula's inputs change, not on every render cycle.
With your state vocabulary defined, build a lookup table that maps states to visual properties. Do this in App.OnStart (or better, in a named formula if it doesn't depend on dynamic data):
// App.OnStart
ClearCollect(
colStyleMap,
{
StateToken: "draft",
BadgeColor: RGBA(158, 158, 158, 1),
BadgeTextColor: RGBA(255, 255, 255, 1),
RowHighlight: RGBA(250, 250, 250, 1),
IconName: "Edit",
StatusLabel: "Draft"
},
{
StateToken: "pending_approval",
BadgeColor: RGBA(255, 193, 7, 1),
BadgeTextColor: RGBA(33, 33, 33, 1),
RowHighlight: RGBA(255, 249, 196, 1),
IconName: "Clock",
StatusLabel: "Awaiting Approval"
},
{
StateToken: "overdue",
BadgeColor: RGBA(244, 67, 54, 1),
BadgeTextColor: RGBA(255, 255, 255, 1),
RowHighlight: RGBA(255, 235, 238, 1),
IconName: "Warning",
StatusLabel: "Overdue — Action Required"
},
{
StateToken: "rejected",
BadgeColor: RGBA(183, 28, 28, 1),
BadgeTextColor: RGBA(255, 255, 255, 1),
RowHighlight: RGBA(253, 234, 234, 1),
IconName: "Cancel",
StatusLabel: "Rejected"
},
{
StateToken: "approved",
BadgeColor: RGBA(76, 175, 80, 1),
BadgeTextColor: RGBA(255, 255, 255, 1),
RowHighlight: RGBA(232, 245, 233, 1),
IconName: "CheckMark",
StatusLabel: "Approved"
},
{
StateToken: "pending_po_generation",
BadgeColor: RGBA(33, 150, 243, 1),
BadgeTextColor: RGBA(255, 255, 255, 1),
RowHighlight: RGBA(227, 242, 253, 1),
IconName: "DocumentApproval",
StatusLabel: "Pending PO Generation"
}
);
Now your controls pull from this map using LookUp:
// Badge background color on a gallery label
LookUp(colStyleMap, StateToken = POStateToken(ThisItem)).BadgeColor
When the business decides that "overdue" should be orange instead of red, you change one record in colStyleMap. Every badge, row highlight, and icon across your entire app updates automatically.
Warning
LookUp on a collection is not delegable to a remote data source — but since colStyleMap is a local in-memory collection loaded at startup, delegation doesn't apply. The collection is small (a handful of rows), so LookUp is near-instantaneous. Don't let delegation warnings in the studio spook you here; they're not performance issues for local collections.
Conditional formatting based on user role is powerful but frequently implemented in a way that causes cascading re-evaluations. The key discipline is: resolve the user's role exactly once, at startup, and store it as an immutable app variable.
The most reliable enterprise pattern for role resolution is Azure AD group membership. The detailed mechanics are covered in Implementing Role-Based Screen Access and Dynamic UI in Canvas Apps Using Azure AD Group Membership, but here's the pattern in context.
In App.OnStart, after your style map collection is built:
// App.OnStart (continued)
Set(
gblCurrentUserRole,
If(
// Check Finance group membership via Office365Groups connector
Office365Groups.IsMemberOf("your-finance-group-id").value,
"Finance",
Office365Groups.IsMemberOf("your-approvers-group-id").value,
"Approver",
Office365Groups.IsMemberOf("your-procurement-group-id").value,
"Procurement",
"ReadOnly"
)
);
// Also cache the user record for display purposes
Set(
gblCurrentUser,
{
Email: User().Email,
FullName: User().FullName,
Role: gblCurrentUserRole
}
);
Tip
The Office365Groups.IsMemberOf() connector call is a network operation. Make it once at startup rather than embedding it in control properties. If you call it inside a Fill or Color property, it fires on every re-render — potentially dozens of times per second as the user interacts with the app. Always resolve roles to a variable at OnStart.
With the role established, create a second lookup structure that captures which controls are relevant for which roles:
ClearCollect(
colRoleCapabilities,
{
Role: "Finance",
CanApprove: true,
CanEditBudget: true,
CanViewCostCenter: true,
CanExport: true,
CanReopen: true
},
{
Role: "Approver",
CanApprove: true,
CanEditBudget: false,
CanViewCostCenter: false,
CanExport: false,
CanReopen: false
},
{
Role: "Procurement",
CanApprove: false,
CanEditBudget: false,
CanViewCostCenter: true,
CanExport: true,
CanReopen: false
},
{
Role: "ReadOnly",
CanApprove: false,
CanEditBudget: false,
CanViewCostCenter: false,
CanExport: false,
CanReopen: false
}
);
// Create a shortcut variable for the current user's capabilities
Set(
gblUserCaps,
LookUp(colRoleCapabilities, Role = gblCurrentUserRole)
);
Now in your controls, you write capability checks instead of role checks:
// Approve button Visible property
gblUserCaps.CanApprove
// Budget input DisplayMode
If(gblUserCaps.CanEditBudget, DisplayMode.Edit, DisplayMode.View)
This is a crucial design decision. Capabilities are more stable than roles. If you add a "Senior Approver" role later that can also edit budgets, you add one row to colRoleCapabilities and set CanEditBudget: true. Nothing else changes. If you'd been checking gblCurrentUserRole = "Finance" everywhere, you'd have a search-and-replace task across every screen.
Now comes the part that most articles stop at: the actual wiring. We'll cover the three main styling axes — Color/Fill, Visible, and DisplayMode — with realistic examples.
Inside a gallery template, you have access to ThisItem, which represents the current record. Combine it with your state map:
// Gallery row background (Fill property of the gallery template)
LookUp(colStyleMap, StateToken = POStateToken(ThisItem)).RowHighlight
// Status badge label (Color property)
LookUp(colStyleMap, StateToken = POStateToken(ThisItem)).BadgeTextColor
// Status badge container (Fill property)
LookUp(colStyleMap, StateToken = POStateToken(ThisItem)).BadgeColor
// Status label text
LookUp(colStyleMap, StateToken = POStateToken(ThisItem)).StatusLabel
Notice that every styling decision for a gallery row flows through the same LookUp call with the same POStateToken(ThisItem) token. The formula engine is smart enough to evaluate POStateToken(ThisItem) once per record per render, but you can optimize further by caching the token inside the gallery's Items property transformation.
Rather than calling POStateToken(ThisItem) multiple times per row, compute it once in the Items property:
// Gallery Items property
AddColumns(
Filter(
colPurchaseOrders,
// your filter criteria
IsBlank(SearchInput.Text) || Title in SearchInput.Text
),
"StateToken", POStateToken(ThisRecord)
)
Warning
AddColumns with a named formula argument works beautifully on local collections but is not delegable when applied to a remote data source like SharePoint or Dataverse. If your source has more than your delegation limit (typically 500-2000 rows), pre-compute state tokens differently — either through a Dataverse calculated column, a server-side view, or by pulling data into a collection first. The Power Apps Performance Optimization: Delegation, Data Row Limits, and Reducing App Load Times article has a thorough treatment of this trade-off.
With StateToken pre-computed as a column, your controls now reference ThisItem.StateToken directly:
// Now the Fill property becomes:
LookUp(colStyleMap, StateToken = ThisItem.StateToken).RowHighlight
This is slightly more efficient and eliminates any risk of POStateToken being computed multiple times per row.
Visible is a boolean property, which makes it the most straightforward of the three axes — but it has a subtlety that trips people up: invisible controls still exist in the DOM and can still receive focus, and their formulas still evaluate. This is different from actually removing the control.
For role-based visibility:
// "Reject" button Visible property
gblUserCaps.CanApprove && POStateToken(galPO.Selected) = "pending_approval"
This button only appears if the user can approve AND the selected record is actually in an approvable state. Notice the conjunction of a capability check and a state check. This is the right pattern — don't just hide based on role, hide based on role AND the current data context. An approver shouldn't see the "Approve" button on a record that's already approved.
For more complex scenarios, build a matrix:
// Button visibility based on state AND role
Switch(
POStateToken(galPO.Selected),
"draft", gblUserCaps.CanApprove = false, // Only requester sees draft actions
"pending_approval", gblUserCaps.CanApprove,
"rejected", gblCurrentUserRole = "Procurement",
"approved", false,
false
)
Key insight
Using Switch instead of nested If when you're branching on a single value is cleaner and slightly more readable, but they're functionally equivalent in Power Fx. Choose Switch when the first argument is the same expression across all branches; use If for conditions that vary independently.
DisplayMode is arguably the most powerful and least understood styling axis. It takes one of three values: DisplayMode.Edit, DisplayMode.View, and DisplayMode.Disabled. Here's what each means in practice:
The distinction between View and Disabled is important for user experience. Use View when the user shouldn't edit something but still needs to read it clearly. Use Disabled when you want to signal that an action is blocked (like a button that can't be clicked because a required field is empty).
For a realistic form scenario:
// Budget field DisplayMode
If(
gblUserCaps.CanEditBudget &&
POStateToken(frmPurchaseOrder.LastSubmit) <> "approved" &&
POStateToken(frmPurchaseOrder.LastSubmit) <> "rejected",
DisplayMode.Edit,
DisplayMode.View
)
This formula says: you can edit the budget field only if your role allows it AND the record isn't already in a terminal state.
For buttons, Disabled is often more appropriate than Visible = false, because showing a grayed-out button communicates "this action exists but isn't available right now," which is often better UX than silently hiding the option:
// Submit button DisplayMode
If(
IsBlank(txtTitle.Text) || IsBlank(txtVendor.Text) || IsBlank(txtAmount.Text),
DisplayMode.Disabled,
DisplayMode.Edit
)
The most realistic scenario combines both dimensions:
// Line item edit field DisplayMode
If(
// Role must allow editing
!gblUserCaps.CanEditBudget,
DisplayMode.View,
// Record must be in an editable state
POStateToken(galPO.Selected) = "approved" ||
POStateToken(galPO.Selected) = "rejected",
DisplayMode.View,
// Finance can reopen rejected records
POStateToken(galPO.Selected) = "rejected" && gblUserCaps.CanReopen,
DisplayMode.Edit,
// Default to edit
DisplayMode.Edit
)
This is getting complex. Let's refactor it through our capabilities system:
// In colRoleCapabilities, add a computed field:
// CanEditRecord is: CanEditBudget AND record is in editable state
// Or better: use a named formula
CanEditPORecord(userCaps, poRecord) =
userCaps.CanEditBudget &&
!(POStateToken(poRecord) = "approved") &&
!(POStateToken(poRecord) = "rejected" && !userCaps.CanReopen);
Then the DisplayMode formula becomes:
If(CanEditPORecord(gblUserCaps, galPO.Selected), DisplayMode.Edit, DisplayMode.View)
Clean, readable, and the logic lives in one named formula.
Forms in Canvas Apps have their own quirks when it comes to conditional formatting. The form's DisplayMode property cascades down to all child data cards — which is convenient for bulk read/write switching but can override per-field logic.
If you set a form's DisplayMode to DisplayMode.View, all fields become read-only regardless of their individual DisplayMode settings. This is useful when you want an entire form to be read-only for certain states:
// Form DisplayMode
If(
POStateToken(galPO.Selected) = "approved",
DisplayMode.View,
DisplayMode.Edit
)
But what if you want most fields to be read-only while one specific field (say, "Approver Comments") remains editable? You need to break the cascade.
In the data card for Approver Comments, unlock the card (right-click the data card, select "Unlock to change properties"), then set the card's DisplayMode explicitly:
// Approver Comments data card DisplayMode
If(
gblUserCaps.CanApprove &&
POStateToken(galPO.Selected) = "pending_approval",
DisplayMode.Edit,
DisplayMode.View
)
This overrides the form's inherited DisplayMode for this specific card.
Warning
When you unlock a data card, you take on responsibility for its behavior. The card's Update property (which controls what gets written back to the data source) is now your responsibility to maintain. If you later add validation logic, make sure you're referencing DataCardValue.Text or the appropriate control, not the auto-generated reference that may have changed. See Power Apps Data Validation: Using If, IsBlank, and IsMatch to Prevent Bad Data in Forms for safe patterns here.
Another underused pattern: making fields conditionally required based on role or state. The Required property of a data card can be dynamic:
// Cost Center data card Required property
gblUserCaps.CanViewCostCenter &&
POStateToken(galPO.Selected) = "draft"
Combined with the DisplayMode.View pattern, you can build forms that silently adjust their requirements based on who's filling them out and what state the record is in.
When you have 15+ screens all using the same conditional formatting logic, the architecture decisions made early start paying dividends. Here's how to scale cleanly.
If the same styled control pattern appears on multiple screens (a status badge, for example), build it as a component. Components in Canvas Apps can accept input properties — including color properties. This is covered in depth in Power Apps Components: Build Reusable UI Elements for Enterprise Scale, but the key point for conditional formatting is: components let you centralize both the visual structure and the style lookup.
A StatusBadge component might accept:
StateToken (Text input property)BadgeColor (Color input property, default: LookUp(colStyleMap, StateToken = Self.StateToken).BadgeColor)LabelText (Text input property, default: LookUp(colStyleMap, StateToken = Self.StateToken).StatusLabel)When you place this component in a gallery, you pass ThisItem.StateToken as the StateToken input. All the styling logic is encapsulated inside the component and maintained in one place.
In a gallery with 50 rows showing 6 styled controls per row, you're potentially executing LookUp(colStyleMap, ...) 300 times on each render. Since colStyleMap is a small in-memory collection, these are fast — but it's still worth being thoughtful.
One optimization: if your named formula POStateToken is expensive (involves multiple comparisons), pre-compute it in the Items property as shown earlier. The AddColumns approach evaluates the function once per row, and all six styled controls in that row read ThisItem.StateToken instead of recomputing it.
Another optimization: avoid embedding LookUp calls in Color and Fill properties that change on hover or focus (like buttons). Power Apps re-evaluates hover-dependent properties very frequently. If a button's color changes based on role and state, compute a single color value at the OnSelect level or derive it from a variable rather than re-running a LookUp on every mouse movement.
Tip
Use the Monitor tool to check how frequently your conditional formatting formulas are evaluating. If you see your LookUp(colStyleMap, ...) call appearing hundreds of times per second in the trace, you have a re-evaluation problem. The Debugging Canvas Apps: Using the Power Apps Monitor Tool and Formula Errors to Fix Issues Fast article walks through exactly this kind of performance investigation.
Your style map handles state-based colors, but your app also needs a consistent base palette. Establish theme variables alongside your style map:
// App.OnStart
Set(
gblTheme,
{
Primary: RGBA(25, 118, 210, 1),
PrimaryLight: RGBA(227, 242, 253, 1),
Surface: RGBA(255, 255, 255, 1),
OnSurface: RGBA(33, 33, 33, 1),
Error: RGBA(211, 47, 47, 1),
Disabled: RGBA(189, 189, 189, 1),
DisabledText: RGBA(117, 117, 117, 1),
FontFamily: "Segoe UI",
FontSizeBody: 14,
FontSizeLarge: 18,
BorderRadius: 4,
CardShadow: RGBA(0, 0, 0, 0.08)
}
);
Use gblTheme.Primary instead of hardcoding RGBA values in your controls. When the design team wants to rebrand, you change 12 lines in OnStart rather than hunting through every property on every screen.
This integrates naturally with the approach covered in Power Apps Design Patterns: Responsive Layouts and Themes and ensures your conditional formatting decisions (state-based overrides) work on top of a consistent foundation rather than fighting with it.
Sometimes you need to style a record based on something computed across multiple records — not just the record's own fields. For example, flagging a vendor as "high spend" if their total approved POs this quarter exceed a threshold.
Build an aggregation into your app startup:
// App.OnStart
ClearCollect(
colVendorSpendSummary,
AddColumns(
GroupBy(
Filter(
colPurchaseOrders,
ApprovalStatus = "Approved" &&
SubmittedDate >= Date(Year(Today()),
RoundDown((Month(Today())-1)/3, 0)*3+1,
1)
),
"VendorID",
"VendorPOs"
),
"TotalSpend", Sum(VendorPOs, Amount),
"IsHighSpend", Sum(VendorPOs, Amount) > 50000
)
);
Then in your gallery:
// Row highlight for high-spend vendors
If(
LookUp(colVendorSpendSummary, VendorID = ThisItem.VendorID).IsHighSpend,
RGBA(255, 243, 224, 1), // Amber tint
LookUp(colStyleMap, StateToken = ThisItem.StateToken).RowHighlight
)
This pattern requires the aggregation to be pre-computed at startup (or refreshed on demand), not computed live inside the gallery. Doing a Filter + Sum inside a gallery's Fill property on every render would be a serious performance problem.
For approval workflows, a progress indicator that visually represents where in a multi-step process a record sits is a common requirement. Build this with a collection of steps and position-based highlighting:
ClearCollect(
colWorkflowSteps,
{ Step: 1, Label: "Draft", Token: "draft" },
{ Step: 2, Label: "Submitted", Token: "pending_approval" },
{ Step: 3, Label: "Approved", Token: "approved" },
{ Step: 4, Label: "PO Issued", Token: "pending_po_generation" }
);
Inside a horizontal gallery bound to colWorkflowSteps:
// Step circle fill
If(
ThisItem.Step <= LookUp(colWorkflowSteps, Token = POStateToken(gblSelectedPO)).Step,
gblTheme.Primary,
gblTheme.Disabled
)
// Step label color
If(
ThisItem.Step <= LookUp(colWorkflowSteps, Token = POStateToken(gblSelectedPO)).Step,
gblTheme.Primary,
gblTheme.DisabledText
)
This gives you a progress stepper that automatically advances based on the current record state.
For compliance scenarios where certain fields should be masked for unauthorized users:
// Cost Center value label text
If(
gblUserCaps.CanViewCostCenter,
ThisItem.CostCenter,
"••••••"
)
Combine with a lock icon that appears for unauthorized users:
// Lock icon Visible
!gblUserCaps.CanViewCostCenter
This creates a visual affordance that the field exists but is restricted — better than hiding it entirely, which might confuse users about why the layout looks incomplete.
Warning
Visual masking in Canvas Apps is a UX technique, not a security control. The underlying data is still loaded into the app's memory. For genuine data security, you must enforce row and column-level permissions at the data source level — in Dataverse through security roles, or in SharePoint through column-level permissions. Do not rely on Visible = false or text masking to protect sensitive data. The Power Apps Security: Roles, Sharing, and Data Permissions article covers the correct approach.
Build a simplified version of the procurement app described in this lesson. You'll need a SharePoint list called PurchaseOrders with these columns:
Title (Text)Amount (Number)VendorName (Text)ApprovalStatus (Choice: Draft, Pending, Approved, Rejected)SubmittedDate (Date)RequestorEmail (Text)Step 1: Set up the style map
In App.OnStart, build colStyleMap with at minimum four state entries: draft, pending_approval, approved, rejected. Use distinct, accessible colors for each state. (Check the Canvas App Accessibility Compliance: WCAG Standards, Screen Reader Support, and Keyboard Navigation for Enterprise Power Apps guidance to ensure your color choices meet 4.5:1 contrast ratios.)
Step 2: Write the state resolution formula
In App.Formulas, write POStateToken(poRecord) that returns the correct token based on ApprovalStatus and a date comparison for overdue detection (more than 7 days in Pending status).
Step 3: Set up role resolution
Simulate role resolution by creating a dropdown on a "settings" screen that lets you switch between Finance, Approver, Procurement, and ReadOnly roles. In a real app, this would be AAD-based, but for testing, a Set(gblCurrentUserRole, drpRoleSimulator.Selected.Value) call works. Build colRoleCapabilities and set gblUserCaps.
Step 4: Build a styled gallery
Create a gallery bound to your SharePoint list. Pre-compute state tokens in the Items property using AddColumns. Style each row's background using the style map, add a status badge label, and add a colored left border strip (a narrow rectangle with Fill set to the badge color).
Step 5: Add role-conditional controls
Add three buttons to a detail panel: "Approve," "Reject," and "Edit." Set their Visible and DisplayMode properties based on gblUserCaps and POStateToken(galPO.Selected). Test by switching roles in your settings screen.
Step 6: Add a DisplayMode-controlled form
Add a form for editing PO details. Set the form's DisplayMode to lock it when the record is in a terminal state. Unlock the "Approver Comments" data card and keep it editable for approvers even on approved records.
The symptom: app feels sluggish, especially when scrolling galleries or switching screens. The cause: Office365Groups.IsMemberOf() or a Filter against Users table is embedded in a Visible or Color property. Every re-render fires the network call.
The fix: resolve role in OnStart, store it in a global variable, reference only the variable in control properties.
Hiding a button for unauthorized users is a UX choice. The data is still loaded. If a user opens the browser DevTools or the Monitor tool, they can see it. For actions that modify data (like Approve, Delete, Export), enforce authorization in Power Automate flows or at the API/data source level, not just by hiding the button.
You've built your style map, but some records show no styling (or throw an error on LookUp returning blank). Check that POStateToken covers all possible states in your data. Add a fallback row to colStyleMap with StateToken: "unknown" and neutral styling so unrecognized states degrade gracefully.
After unlocking a data card to set its DisplayMode, the form submits but that field's value doesn't save. The unlocked card's Update property (used by SubmitForm) may have lost its reference. Check the Update property on the data card — it should reference DataCardValue.Text (or .Value / .SelectedDate depending on the control type). Re-enter it if it shows an error.
You added AddColumns(SharePointList, "StateToken", POStateToken(ThisRecord)) to your gallery's Items and got a delegation warning. With large lists, only the first 2000 records are processed, so records beyond that limit will have blank StateToken values — causing styling failures.
The fix depends on your data size. For lists under 2000 rows: suppress the warning and document why it's acceptable. For larger lists: either add a calculated column in Dataverse that mirrors your state logic, or load data into a collection first (with proper pagination), then apply AddColumns to the collection.
You're using Switch(POStateToken(ThisItem), "draft", ..., "pending_approval", ...) and some branches seem to match incorrectly. Remember that Switch in Power Fx uses exact equality — check for trailing spaces or case differences in your state token strings. Use Text(...) or Lower(...) normalization if data comes from user-entered fields.
A user has been added to the Approvers AAD group, but when they open the app, they still see ReadOnly behavior. This is expected — gblCurrentUserRole is resolved at OnStart. The user needs to close and reopen the app (which triggers OnStart). If real-time role updates are required, you need a refresh mechanism: a "Refresh Role" button that re-runs the role resolution logic and calls Set(gblCurrentUserRole, ...) again.
Conditional formatting at scale is fundamentally an architecture problem, not a formula problem. The individual If statements and LookUp calls are simple — the challenge is organizing them so the system stays maintainable as complexity grows.
The three-layer model we built here keeps concerns separate:
POStateToken) translates raw data into a consistent vocabularygblUserCaps) captures permissions once at startupcolStyleMap + LookUp) maps states and capabilities to visual properties through a lookup table rather than hardcoded logicThe payoff is significant. When a stakeholder asks to change the "Overdue" badge from red to orange, you change one record in colStyleMap. When a new "Auditor" role is added, you add one row to colRoleCapabilities and the UI adjusts everywhere. When a new status like "On Hold" needs to be added, you add one entry to colStyleMap and one branch in POStateToken.
From here, consider these natural next steps:
The goal is an app that communicates state clearly, enforces appropriate permissions, and can evolve with your business — without requiring you to search through hundreds of control properties every time a requirement changes. With the architecture in this lesson, you're building that kind of app.