Learn how to build loading spinners, empty state messages, and skeleton screens in Power Apps Canvas Apps. This hands-on lesson walks through all three async UX patterns with complete formulas, a realistic work order app scenario, and troubleshooting guidance.

You've built the app. The data connection works. The gallery populates correctly. But the moment a user navigates to the screen, there's that awkward half-second — or three-second — window where the gallery sits empty, the form shows blank fields, and nothing communicates whether the app is working or broken. Users click the button again. They navigate away. They call the helpdesk. They decide the app "doesn't work."
This is the async data problem, and it's one of the most common causes of user abandonment in Power Apps. Canvas apps fetch data from SharePoint, Dataverse, SQL Server, and external APIs — all of which have latency. The app renders the UI immediately, but the data arrives later. Without deliberate loading states, that gap feels like failure. The good news is that with a handful of variables, some clever layering, and a few well-placed UI controls, you can turn that awkward silence into a polished, professional experience that communicates exactly what the app is doing at every moment.
By the end of this lesson, you'll know how to build three essential async UX patterns from scratch: loading spinners that block the UI while data loads, empty state messages that distinguish "no results" from "still loading," and skeleton screens that give users a structural preview of content before it arrives. You'll implement all three in a realistic field service work order app connected to SharePoint, and you'll understand exactly why each pattern works — so you can adapt them to your own scenarios.
What you'll learn:
This lesson assumes you're comfortable with Canvas App fundamentals — building screens, connecting data sources, and writing basic formulas. You should understand how variables work (both global and context variables); if you want a refresher, the lesson on Power Apps Variables Explained: When to Use Global Variables, Context Variables, and Collections in Canvas Apps covers everything you need. You should also have a working data connection in place — if you're still setting that up, Connecting Power Apps to SharePoint, Excel, and Dataverse: A Complete Integration Guide is a solid starting point.
For the hands-on exercise, we'll use a SharePoint list called WorkOrders with these columns: Title (text), AssignedTech (person), Status (choice: Open / In Progress / Closed), Priority (choice: Low / Medium / High), and ScheduledDate (date).
Before you can solve this problem, you need a clear mental model of what's actually happening when a Canvas App loads data.
When a user navigates to a screen, Power Apps does two things almost simultaneously: it renders all the visible controls (immediately, because they're client-side), and it fires any data queries defined in properties like Items or OnVisible (asynchronously, because they require a network round trip). The control rendering wins the race every time. Your gallery appears on screen before the data is ready, which means users see an empty gallery for however long the network round trip takes.
In development, on a fast corporate network, that might be 200 milliseconds — barely noticeable. In production, over a mobile connection or through a slow SharePoint Online tenant, it can be two to five seconds. And even on fast connections, if you're using ClearCollect in OnVisible to pre-load a collection (a pattern covered in Power Apps Performance Optimization: Delegation, Data Row Limits, and Reducing App Load Times), the gap can be significant.
The UX failure here isn't just aesthetic. It's cognitive. When a user sees a blank gallery, their brain interprets one of three things: the app is loading, there are no results, or something broke. Without a signal, they can't tell which is true — so they guess, usually badly.
Key insight
The goal of loading UX patterns isn't to make your app faster. It's to make the wait legible. Users tolerate latency much better when they understand it's happening intentionally. Research consistently shows that perceived wait time is significantly lower when a progress indicator is present — even when the actual wait time is identical.
Three patterns address different parts of this problem:
Each pattern has its place, and a polished app uses all three in the right context.
The loading spinner is the most fundamental async UX pattern and the easiest to implement. The core idea is simple: you create a semi-transparent full-screen overlay with a spinner animation, then show or hide it based on a Boolean variable.
In your screen's OnVisible property, you'll control data loading explicitly using ClearCollect. This gives you hooks to set loading state before and after the fetch:
// Screen OnVisible
Set(varIsLoading, true);
ClearCollect(
colWorkOrders,
Filter(
WorkOrders,
Status.Value <> "Closed"
)
);
Set(varIsLoading, false)
This is a synchronous sequence in Power Apps formula language — Set(varIsLoading, true) runs before ClearCollect, and Set(varIsLoading, false) runs after it completes. That gap between the two Set calls is exactly when your spinner should be visible.
Warning
If you connect your gallery's Items property directly to a data source (e.g., Items = Filter(WorkOrders, Status.Value <> "Closed")) rather than using ClearCollect in OnVisible, you lose control over the loading lifecycle. The query fires implicitly and you have no reliable hook to set varIsLoading. Always use ClearCollect into a collection when you need loading state control.
Now build the overlay. In the Power Apps studio, add the following controls to your screen and arrange them at the very top of the layer order (so they sit above everything else):
1. Full-screen rectangle (the backdrop)
rectLoadingOverlayParent.WidthParent.Height00RGBA(255, 255, 255, 0.85) — a nearly opaque white, letting the content underneath hint throughvarIsLoading2. Spinner circle (the animated indicator)
Power Apps doesn't have a native spinner control, but you can simulate one effectively with a rotating image. Use the built-in Timer control combined with a rotating Icon:
icnSpinner4848(Parent.Width / 2) - 24(Parent.Height / 2) - 60Color.DodgerBluetimerSpinner.Value * 0.36 — this converts milliseconds to degreesvarIsLoading3. Timer control (drives the rotation)
timerSpinner1000 (one full cycle per second)truetruefalse — timers can be invisible while still runningvarIsLoadingThe math here: the timer counts from 0 to 1000 milliseconds repeatedly. Multiplying timerSpinner.Value by 0.36 maps that 0–1000 range to 0–360 degrees. One full rotation per second. Clean and smooth.
4. Loading message label
lblLoadingMessage"Loading work orders..."(Parent.Width / 2) - 100icnSpinner.Y + icnSpinner.Height + 12200Align.CenterRGBA(66, 66, 66, 1)varIsLoadingGroup all four controls into a group named grpLoadingOverlay. This makes it easier to manage z-ordering and visibility as a unit.
Tip
In the Tree View panel, drag grpLoadingOverlay to the very top of the controls list for your screen. In Power Apps, controls higher in the list render on top of controls below them. If your overlay is buried under other controls, it won't visually block them even when visible.
To test without waiting for real network latency, temporarily modify your OnVisible to include a deliberate delay:
Set(varIsLoading, true);
ClearCollect(colWorkOrders, WorkOrders);
// Simulate delay for testing:
Set(varTestDelay, Now());
Concurrent(
Set(varIsLoading, CountRows(colWorkOrders) >= 0) // always false first
);
Set(varIsLoading, false)
Actually, the cleanest way to test the visual: temporarily set varIsLoading to true in the App OnStart and check the layout in the studio preview. Then revert.
Once data has loaded, you face the second UX problem: what if the result set is genuinely empty? An empty gallery with no spinner is indistinguishable from a load that hasn't finished yet. You need an empty state that communicates clearly.
Your gallery view has three possible states, and your UI needs to represent all of them distinctly:
| State | varIsLoading |
CountRows(colWorkOrders) |
What to show |
|---|---|---|---|
| Loading | true |
Any | Loading spinner overlay |
| Loaded, no results | false |
0 |
Empty state message |
| Loaded, has results | false |
> 0 |
Gallery with data |
The spinner handles state 1. Now build the empty state for state 2.
An empty state isn't just "No records found." A good empty state does three things: it confirms the load completed successfully, it explains why there's nothing to show, and it gives the user an action to take. Think of it as a micro-conversation with the user.
For the work order app, an appropriate empty state for a tech with no assigned open orders might read:
Build this as a group of controls:
Container: grpEmptyState
Place all empty state controls in a vertical container or group:
Icon control (icnEmptyState): Use the "Document" or "Inbox" icon
64(Parent.Width / 2) - 32200RGBA(180, 180, 180, 1)Headline label (lblEmptyHeadline):
"You're all caught up"18FontWeight.BoldRGBA(50, 50, 50, 1)Align.CenterParent.Width * 0.6Parent.Width * 0.2icnEmptyState.Y + icnEmptyState.Height + 16Subtext label (lblEmptySubtext):
"There are no open work orders matching your current filters."13RGBA(120, 120, 120, 1)Align.CenterParent.Width * 0.5Parent.Width * 0.25lblEmptyHeadline.Y + lblEmptyHeadline.Height + 8Action button (btnEmptyCTA):
"View all work orders"Set(varFilterMyOrders, false); ClearCollect(colWorkOrders, WorkOrders)200(Parent.Width / 2) - 100lblEmptySubtext.Y + lblEmptySubtext.Height + 24Now set the visibility of the entire group:
// grpEmptyState Visible property
!varIsLoading && CountRows(colWorkOrders) = 0
And set the gallery's visibility to show only when there's actually data:
// galWorkOrders Visible property
!varIsLoading && CountRows(colWorkOrders) > 0
Key insight
The !varIsLoading guard in both conditions is critical. Without it, you'll get a flash of the empty state between when the screen renders and when ClearCollect completes — because CountRows(colWorkOrders) is 0 at the start before the data arrives. Always gate empty state visibility on the loading flag being false.
Generic "no results" messages are missed opportunities. You can make the message adapt to why there are no results by checking the active filter:
// lblEmptyHeadline Text property
If(
!IsBlank(txtSearchBox.Text),
"No results for """ & txtSearchBox.Text & """",
varFilterMyOrders,
"You're all caught up",
"No work orders found"
)
// lblEmptySubtext Text property
If(
!IsBlank(txtSearchBox.Text),
"Try a different search term or clear the search to see all work orders.",
varFilterMyOrders,
"There are no open work orders assigned to you right now.",
"There are no work orders in the system yet. Create one to get started."
)
This kind of contextual messaging — where the empty state responds to user actions and current filter state — is what separates a good app from a great one. For more patterns around dynamic UI text, the lesson on Master Power Apps Formulas: Navigate, Filter, Lookup, and Patch for Professional Apps has relevant formula patterns.
Skeleton screens are the most sophisticated of the three patterns, and they pay the biggest dividends for content-heavy layouts. Instead of a spinner (which says "wait, something is happening"), a skeleton screen says "here's the shape of what's coming." Users can orient themselves to the layout before the data arrives, which dramatically reduces the perception of latency.
The classic example is LinkedIn's feed: those gray animated bars you see before posts load. Each bar corresponds roughly to where text or an image will appear. When the real content arrives, it slots into a layout the user already understands.
Your skeleton screen should mirror the visual structure of your real content — same number of rows, same proportional widths, same vertical rhythm. The difference is that instead of real text and images, you render gray rounded rectangles. Add a subtle shimmer animation (using the same Timer trick from the spinner) and it looks remarkably polished.
For the work order gallery, each real gallery row contains:
Your skeleton row should echo this structure exactly:
Create a new gallery control named galSkeleton:
[1, 2, 3, 4, 5] — a hardcoded table of 5 rows (this is a static placeholder, not real data)galWorkOrders.WidthgalWorkOrders.XgalWorkOrders.YgalWorkOrders.HeightgalWorkOrders.TemplateSize — match your real gallery's row heightvarIsLoadingInside the skeleton gallery template, add rectangle controls to mimic each element. The shimmer effect comes from the Timer:
Badge circle placeholder:
cirSkeletonBadge32, Height: 3212, Y: (Parent.TemplateHeight / 2) - 16ColorFade(Color.Silver, timerSkeleton.Value / 1000 * 0.4)Title placeholder:
rectSkeletonTitleParent.Width - 641652, Y: 208 (rounded ends)ColorFade(Color.Silver, timerSkeleton.Value / 1000 * 0.4)Tech name placeholder:
rectSkeletonTech1401252rectSkeletonTitle.Y + rectSkeletonTitle.Height + 86ColorFade(Color.Silver, timerSkeleton.Value / 1000 * 0.4)Status chip placeholder:
rectSkeletonStatus8020Parent.Width - 100rectSkeletonTitle.Y10ColorFade(Color.Silver, timerSkeleton.Value / 1000 * 0.4)The shimmer timer:
Add a Timer control named timerSkeleton to the screen (not inside the gallery):
1200truetruevarIsLoadingfalseThe ColorFade function is doing the work here. It lightens Color.Silver by an amount that oscillates between 0 and 0.4 as the timer counts from 0 to 1200. The result is a gentle pulse from medium gray to light gray and back — the classic shimmer.
Tip
ColorFade(color, fadeFactor) takes a factor from -1 (fully dark) to 1 (fully white). Using timerSkeleton.Value / 1000 * 0.4 means the fade factor oscillates between 0 and 0.48 each cycle — a subtle but clearly visible animation. Increase the multiplier for a more dramatic shimmer, decrease it for subtlety.
The skeleton gallery needs to be positioned exactly on top of the real gallery, with the same TemplateSize, the same X/Y coordinates, and the same Width. When varIsLoading flips to false, the real gallery appears and the skeleton disappears — if they're perfectly aligned, the transition is seamless and the layout doesn't shift at all.
// galWorkOrders Visible property
!varIsLoading && CountRows(colWorkOrders) > 0
// galSkeleton Visible property
varIsLoading
This means the skeleton always shows while loading (regardless of whether results exist), and the real gallery only shows when loading is complete and there are results. The empty state handles the "loaded but empty" case. All three states are now covered.
Warning
Don't try to put the skeleton inside the same gallery as your real content using conditional visibility on individual controls. This creates formula complexity and gallery re-render issues. Separate galleries that overlap each other — skeleton on top, real gallery underneath — is the right architecture. It feels wasteful, but it's clean and performant.
Now let's step back and look at the complete state machine. Every loading UX pattern depends on two variables: varIsLoading (Boolean) and colWorkOrders (collection). Here's the complete OnVisible formula for the screen:
// WorkOrdersScreen OnVisible
Concurrent(
Set(varIsLoading, true),
Set(varFilterMyOrders, true)
);
ClearCollect(
colWorkOrders,
Filter(
WorkOrders,
AssignedTech.Email = User().Email,
Status.Value <> "Closed"
)
);
Set(varIsLoading, false)
Note the use of Concurrent to set both initialization variables at the same time, then the sequential ClearCollect (which must complete before we mark loading as done), and finally clearing the loading flag.
Here's the full visibility logic for all your controls:
| Control | Visible formula |
|---|---|
grpLoadingOverlay (spinner) |
varIsLoading |
galSkeleton |
varIsLoading |
galWorkOrders |
!varIsLoading && CountRows(colWorkOrders) > 0 |
grpEmptyState |
!varIsLoading && CountRows(colWorkOrders) = 0 |
And your screen header, filter controls, and search bar should remain visible throughout all states — they don't need to be hidden during loading. Only the content area changes.
Key insight
The skeleton and the spinner overlay serve different purposes and can coexist. During loading, you see the skeleton (which gives layout context) plus the spinner overlay (which gives motion feedback). You could choose one or the other, but combining them is actually more effective: the skeleton reduces layout anxiety while the spinner communicates active work.
Users will refresh data — either explicitly through a "Refresh" button or implicitly when navigating back to the screen. Make sure your refresh button respects the loading state pattern:
// btnRefresh OnSelect
Set(varIsLoading, true);
ClearCollect(
colWorkOrders,
Filter(
WorkOrders,
AssignedTech.Email = User().Email,
Status.Value <> "Closed"
)
);
Set(varIsLoading, false)
Also disable the refresh button while loading to prevent double-fetches:
// btnRefresh DisplayMode
If(varIsLoading, DisplayMode.Disabled, DisplayMode.Edit)
For broader patterns around error handling during these data operations, the lesson on Canvas App Error Handling: Building Resilient Apps with IfError, Notify, and Graceful Failure Patterns shows how to wrap ClearCollect in IfError to handle network failures gracefully — an important companion to loading states.
Every UX pattern has a performance cost. Here's what to be aware of:
Timer controls are lightweight but not free. Each timer fires a formula evaluation on every tick. Keep timer formulas simple — just the Value property used in a ColorFade or rotation calculation. Never put ClearCollect or Patch inside a timer's OnTimerEnd.
Overlapping galleries (skeleton + real) means Power Apps renders both control trees. The skeleton gallery isn't visible during normal use, but Power Apps still evaluates its properties. Keep the skeleton template simple — rectangles and circles only, no labels with complex formulas.
Collection-based loading (using ClearCollect rather than direct data source binding) adds memory overhead but gives you fine-grained loading state control. For galleries with thousands of records, make sure you're filtering server-side before collecting. See the lesson on Canvas App Delegation Deep Dive: Rewriting Non-Delegable Queries with Named Formulas, Explicit Column Selection, and Server-Side Filtering Patterns for the right approach to filtering large datasets before they hit your collection.
Skeleton rows: Hardcoding 5 rows in the skeleton gallery's Items property ([1, 2, 3, 4, 5]) is fine for most scenarios. If your gallery shows 20 rows, you might want 8–10 skeleton rows. But don't go overboard — off-screen skeleton rows still generate control instances. Keep it to the number of rows visible in the typical viewport.
Tip
If you want to reuse your loading state patterns across multiple screens without rebuilding them each time, encapsulate them as a Component. The lesson on Power Apps Components: Build Reusable UI Elements for Enterprise Scale covers how to build custom components with input properties — perfect for a LoadingOverlay component that accepts IsLoading as an input and encapsulates the spinner, timer, and rectangle internally.
Build the complete loading UX pattern set for the Work Orders screen. Here are your requirements:
Setup:
WorkOrders with columns: Title (single line), AssignedTechEmail (single line), Status (choice: Open/In Progress/Closed), Priority (choice: Low/Medium/High), ScheduledDate (date)Build:
WorkOrdersScreengalWorkOrders) with a row template showing Title, Status chip, Priority indicator, and ScheduledDateOnVisible formula using ClearCollect with a filter on AssignedTechEmail = User().Email and Status <> "Closed"galSkeleton) that mirrors your real gallery's structure with shimmer animationgrpEmptyState) with contextual headline, subtext, and a CTA button that loads all recordsStretch challenge:
Add a search box above the gallery. When the user types, filter colWorkOrders in-memory (don't re-fetch from SharePoint). Update the empty state message to reflect whether the emptiness is due to the search filter or genuine lack of records. Hint: use varCurrentSearch as a variable and Search() function on the collection.
The spinner flashes on and disappears immediately
This usually means varIsLoading is never actually true from the user's perspective — it sets to true and immediately resolves to false because ClearCollect is pulling from a cached collection. Test by adding Collect(colDummy, {x: 1}) before the ClearCollect and checking that the sequence executes. If the data is coming from cache, Power Apps may resolve ClearCollect faster than a single render frame.
The empty state flashes briefly before data loads
You forgot the !varIsLoading && guard in your empty state's Visible formula. The collection starts empty, varIsLoading is briefly false before OnVisible fires, and the empty state appears. The fix: always gate empty state on !varIsLoading.
The skeleton gallery and real gallery are misaligned
Check that both galleries have identical X, Y, Width, and TemplateSize values. If you moved one, the other didn't follow. Group them in the Tree View and use the Align tools (Format menu > Align) to verify positioning. Also check that both have the same LayoutMode setting.
The shimmer animation is choppy
This is often caused by too many controls referencing timerSkeleton.Value simultaneously. Each reference creates a dependency. If you have 5 skeleton rows with 4 shimmer controls each, that's 20 simultaneous formula evaluations every timer tick. Simplify by reducing the number of shimmer rectangles per row, or increase the timer duration (1500ms instead of 1000ms) to reduce the evaluation frequency.
ClearCollect doesn't honor the loading state properly with delegation
If your Filter formula isn't delegable, Power Apps may silently cap results at 500 or 2000 rows and return immediately from local data — meaning varIsLoading flips back to false before real server-side filtering completes. Always check the delegation warning (blue underline) under your Filter formula. For proper server-side delegation patterns, see Power Apps Performance Optimization: Delegation, Data Row Limits, and Reducing App Load Times.
The skeleton shows after data loads
Your skeleton Visible property is set only to varIsLoading but the real gallery has !varIsLoading && CountRows(colWorkOrders) > 0. For a brief moment, both might be visible. This is usually fine visually (the skeleton hides simultaneously with the gallery appearing), but if it bothers you, add a tiny opacity transition by setting the skeleton's Visible to varIsLoading and keeping layer ordering tight.
Warning
Don't set varIsLoading to true in App.OnStart as a global initialization pattern without setting it back to false when the first screen loads. Users who navigate away and return to the screen depend on OnVisible to reset the state. A stale true value left in varIsLoading will lock users into the loading state permanently. Always pair every Set(varIsLoading, true) with a guaranteed Set(varIsLoading, false) — ideally in the same formula chain, not in a different event handler.
You've now built all three core async UX patterns for Canvas Apps:
ClearCollect callCountRows checkThese patterns aren't just cosmetic. They communicate app state to users in a language they understand instinctively — making your app feel professional, trustworthy, and intentional even when network conditions are poor.
The natural next area to explore is what happens when data loading fails entirely. The patterns we built assume success — loading eventually completes. But networks fail, permissions change, and connectors time out. The lesson on Canvas App Error Handling: Building Resilient Apps with IfError, Notify, and Graceful Failure Patterns picks up directly from here, showing you how to add an error state (a fourth state in your UI state machine) that catches ClearCollect failures and displays a meaningful error message rather than a stale spinner.
You might also consider whether these loading patterns belong in Components for reuse across your app. If you're building multiple screens that each load their own data, encapsulating the spinner and skeleton as reusable components will save you significant maintenance overhead as the app scales — the Power Apps Components: Build Reusable UI Elements for Enterprise Scale lesson has you covered there.
Finally, if you want to instrument how long your loading states actually take in production — beyond what you can observe in the studio — Canvas App Performance Profiling in Production: Monitor, Telemetry & Azure Application Insights shows you how to capture real-world timing data and use it to identify which data fetches need optimization.