
Picture this: you're building a field inspection app for a utilities company. Inspectors need to walk through a facility, log equipment readings, flag anomalies, and submit a full report — all while potentially offline in a substation with no cell signal. Your app can't make a round-trip to SharePoint or Dataverse for every tap. It needs to accumulate data locally, let the inspector edit and correct entries on the fly, and then batch everything to the backend when connectivity returns. If you're still thinking "I'll just use a gallery bound directly to my data source," you're about to learn why that approach will collapse under this kind of real-world pressure.
Collections — Power Apps' in-memory, table-shaped data structures — are the answer. They sit inside the app's runtime, respond instantly to user input, support full CRUD operations without a network call, and can be flushed to any connected data source in a controlled, deliberate way. But most practitioners use them only for simple lookup lists or dropdown population. The real power comes when you treat collections as a proper data layer: staging complex workflows, managing multi-step edits, simulating joins, and building offline-capable submission pipelines.
By the end of this lesson, you'll be able to architect a local data management strategy for a Canvas App that goes well beyond basic gallery binding. You'll understand the mechanics of ClearCollect, Collect, Patch, Remove, UpdateIf, and LookUp at a production level — including their performance tradeoffs, their failure modes, and how they interact with each other in complex workflows.
What you'll learn:
ClearCollect, Collect, and Clear actually work under the hood and when to use eachPatch to upsert records into both collections and data sources with full control over which fields you writeYou should already be comfortable with:
If(), Filter(), LookUp()Set) and collections (ClearCollect)If you're still fuzzy on any of those, spend time in the foundational Canvas Apps lessons before continuing.
Before writing a single formula, you need to internalize what a collection actually is. A collection is a named, ordered table stored in your app's memory for the duration of a session. It has columns (which are inferred from the records you put into it), it supports all the table functions Power Apps provides, and it disappears the moment the app session ends — unless you've deliberately saved it using SaveData and LoadData for offline scenarios.
This is different from a global variable (Set), which holds a single value or a single record. A collection holds many records. Think of it as a local SQL table that lives in RAM.
The key behavioral characteristics you need to internalize:
Schema is inferred, not declared. When you first populate a collection, Power Apps infers its schema from the first record. If your first record has columns EquipmentID, Reading, and InspectorName, those become the collection's columns. Adding a record later that includes a Notes column will add that column to the collection — but earlier records will have a blank value for it. This can cause subtle bugs if you're not deliberate about your schema upfront.
Collections are mutable. Unlike data sources, which require specific delegation-aware functions, collections support Remove, RemoveIf, UpdateIf, and Patch without any delegation concerns — because all the data is already in memory.
Collections reset on session end. Unless you use SaveData/LoadData, every time the app is closed or the session times out, your collections evaporate. This is a feature in workflows where you want a clean slate, but it's a gotcha if you're building offline persistence.
These three functions are often treated as interchangeable, but they have meaningfully different behaviors.
ClearCollect(colInspectionItems, Filter(EquipmentList, FacilityID = varCurrentFacility))
ClearCollect does two things atomically: it wipes out whatever was previously in the named collection, then populates it with the records from the second argument. This is your go-to for seeding a collection at app load, at screen navigation, or when you need a clean refresh.
Use ClearCollect when:
A common pattern is calling ClearCollect in the OnStart property of the App or the OnVisible property of a screen:
// In Screen.OnVisible
ClearCollect(
colEquipmentReadings,
Filter(
EquipmentReadings,
FacilityID = varSelectedFacility And
InspectionDate = Today()
)
);
ClearCollect(
colFlaggedItems,
Filter(colEquipmentReadings, Status = "Flagged")
);
Notice that the second ClearCollect is based on the first collection, not a data source. This is a legitimate and useful pattern — you can build derived collections from other collections without any delegation concerns.
Collect(colInspectionItems, {
EquipmentID: txtEquipmentID.Text,
Reading: Value(txtReading.Text),
InspectorName: varCurrentUser.DisplayName,
Timestamp: Now(),
Status: "Pending",
IsSubmitted: false
})
Collect appends one or more records to an existing collection without clearing it first. This is what you use when you're accumulating records over time — like an inspector adding multiple readings during a walkthrough.
Warning: If you call
Collecton a collection name that doesn't exist yet, Power Apps creates it automatically. This sounds convenient, but it means a typo in your collection name will silently create a new, empty collection instead of throwing an error. Always initialize your collections explicitly withClearCollectbefore usingCollect.
Clear(colInspectionItems)
Clear empties the collection but preserves the schema. Use this when you want to reset the collection's data without reloading from a source — for example, when an inspector finishes a report and you want to prepare for the next facility without navigating away from the screen.
Here's what a well-structured initialization looks like for an inspection workflow:
// App.OnStart — seed reference data
ClearCollect(colFacilities, Facilities);
ClearCollect(colEquipmentTypes, EquipmentTypes);
Set(varCurrentUser, Office365Users.MyProfileV2());
// Screen.OnVisible — load current work
ClearCollect(
colCurrentReadings,
Filter(InspectionReadings, FacilityID = varSelectedFacility.ID And IsSubmitted = false)
);
// "Start New Inspection" button
Clear(colCurrentReadings);
Set(varSelectedFacility, {});
Patch is the most powerful — and most misunderstood — function in the Power Apps formula language. It operates on both collections and connected data sources using the same syntax, which means you can prototype your logic against a local collection and then swap in a data source with minimal changes.
Patch(DataSourceOrCollection, BaseRecord, ChangeRecord)
The BaseRecord specifies which record to update. If it matches an existing record (based on the primary key or the record's identity), Patch updates it. If it doesn't match, Patch inserts a new record. This upsert behavior is enormously useful.
The ChangeRecord contains only the fields you want to change. Fields not included in the ChangeRecord are left untouched. This is a critical difference from forms, which write every field.
Here's the scenario: your inspector scans an equipment tag, enters a reading, and hits Save. If they've already entered a reading for this equipment item (maybe they need to correct it), you want to update the existing record. If it's new, you want to insert it.
Patch(
colCurrentReadings,
LookUp(colCurrentReadings, EquipmentID = txtScannedID.Text),
{
EquipmentID: txtScannedID.Text,
Reading: Value(txtReading.Text),
ReadingUnit: ddUnit.Selected.Value,
InspectorName: varCurrentUser.displayName,
Timestamp: Now(),
Status: If(Value(txtReading.Text) > LookUp(colEquipmentTypes, TypeCode = txtEquipmentType.Text, MaxThreshold), "Flagged", "Normal"),
Notes: txtNotes.Text
}
)
The LookUp in the BaseRecord position is the key here. If it finds a matching record in colCurrentReadings, Patch updates it. If LookUp returns blank (no match exists), Patch creates a new record. You get full upsert behavior in a single formula line.
Tip: When
LookUpreturns blank as the BaseRecord, Patch creates a new record using only the fields in the ChangeRecord. Make sure your ChangeRecord includes all required fields when you're in insert mode.
Patch also accepts a table as its second argument, which lets you update multiple records in a single call. This is crucial for performance when submitting a batch:
Patch(
InspectionReadings,
ShowColumns(
AddColumns(
Filter(colCurrentReadings, IsSubmitted = false),
"SubmittedOn", Now(),
"SubmittedBy", varCurrentUser.id
),
"EquipmentID", "Reading", "ReadingUnit", "Status", "Notes",
"InspectorName", "Timestamp", "FacilityID", "SubmittedOn", "SubmittedBy"
)
)
This single Patch call takes every unsubmitted record from the local collection, adds submission metadata via AddColumns, limits the columns to those that exist in the data source via ShowColumns, and pushes them all to InspectionReadings in one operation. Compare this to a ForAll loop calling Patch individually on each record — the batch approach is dramatically faster.
Warning: The batch Patch against a real data source can fail partially — some records may succeed while others fail. Power Apps returns a table of results where failed records include an error column. Always capture the result and check for errors in production workflows.
One of the most sophisticated patterns in Canvas Apps is using a collection as a staging area for edits that haven't been committed yet. This is fundamentally different from editing directly in a connected gallery or form — it gives you the ability to let users make multiple changes, preview them, and either commit or discard the entire batch.
Imagine a scenario where a procurement manager needs to adjust quantities on a purchase order that has ten line items. You don't want each adjustment to immediately hit the database. Instead, you want to let them edit all the lines, review the total impact, and then submit once.
Start by loading the line items into a staging collection when the PO screen opens:
// Screen.OnVisible
ClearCollect(
colPOLineItems_Staged,
AddColumns(
Filter(POLineItems, PurchaseOrderID = varSelectedPO.ID),
"IsModified", false,
"OriginalQuantity", Quantity
)
);
The AddColumns here adds two computed columns that only exist in your staging collection: IsModified (a flag you'll set when the user edits a row) and OriginalQuantity (a snapshot of the original value so you can show the user what changed).
Now, in your gallery's editable quantity field, the OnChange event updates the staging collection:
// Quantity input field in the gallery - OnChange
Patch(
colPOLineItems_Staged,
ThisItem,
{
Quantity: Value(Self.Text),
IsModified: Value(Self.Text) <> ThisItem.OriginalQuantity
}
)
This is the elegance of using Patch with ThisItem as the BaseRecord — it uniquely identifies the specific row in the collection that corresponds to the gallery row being edited.
With your staging collection in place, you can now give the user meaningful feedback before they commit:
// Label showing total cost impact of changes
"Total order value: " &
Text(
Sum(colPOLineItems_Staged, Quantity * UnitPrice),
"[$-en-US]#,##0.00"
) &
" | Changes affect " &
CountIf(colPOLineItems_Staged, IsModified) &
" line items"
When the user hits Submit, you only need to write back the modified records:
// Submit button - OnSelect
If(
CountIf(colPOLineItems_Staged, IsModified) > 0,
Patch(
POLineItems,
Filter(colPOLineItems_Staged, IsModified),
// Note: we need to exclude our staging-only columns
// Use ShowColumns to limit to real data source columns
ShowColumns(
Filter(colPOLineItems_Staged, IsModified),
"ID", "Quantity", "PurchaseOrderID", "ItemCode", "UnitPrice"
)
);
// Refresh the local collection to reflect committed state
ClearCollect(
colPOLineItems_Staged,
AddColumns(
Filter(POLineItems, PurchaseOrderID = varSelectedPO.ID),
"IsModified", false,
"OriginalQuantity", Quantity
)
);
Notify("Changes submitted successfully.", NotificationType.Success),
Notify("No changes to submit.", NotificationType.Warning)
)
Tip: The pattern of
Patchfollowed byClearCollectis your commit-and-refresh cycle. The Patch writes the dirty records, and the ClearCollect reloads clean state from the source of truth. Never skip the refresh step — your local collection will be out of sync with the backend otherwise.
Power Apps doesn't have SQL JOIN syntax, but you can absolutely simulate relational operations using AddColumns, LookUp, Filter, and collection manipulation. This is where practitioners who understand the formula language start building things that feel like real applications.
Suppose you have a collection of inspection readings (colCurrentReadings) and a separate reference collection of equipment metadata (colEquipmentTypes). You want to display readings enriched with equipment display names and thresholds.
ClearCollect(
colReadingsEnriched,
AddColumns(
colCurrentReadings,
"EquipmentDisplayName",
LookUp(colEquipmentTypes, TypeCode = ThisRecord.EquipmentTypeCode, DisplayName),
"MaxThreshold",
LookUp(colEquipmentTypes, TypeCode = ThisRecord.EquipmentTypeCode, MaxThreshold),
"IsOverThreshold",
LookUp(colEquipmentTypes, TypeCode = ThisRecord.EquipmentTypeCode, MaxThreshold) < ThisRecord.Reading
)
)
This creates a new collection where every reading record is enriched with data from the equipment types reference table — effectively an inner join. The LookUp calls execute against the in-memory colEquipmentTypes, so they're fast regardless of its size.
ClearCollect(
colInspectionSummary,
GroupBy(colCurrentReadings, "EquipmentTypeCode", "ReadingsByType")
);
ClearCollect(
colInspectionSummary,
AddColumns(
colInspectionSummary,
"TypeDisplayName",
LookUp(colEquipmentTypes, TypeCode = ThisRecord.EquipmentTypeCode, DisplayName),
"AverageReading",
Average(ReadingsByType, Reading),
"FlaggedCount",
CountIf(ReadingsByType, Status = "Flagged"),
"TotalCount",
CountRows(ReadingsByType)
)
)
GroupBy creates a collection where each row represents a unique value of EquipmentTypeCode, with a nested table of all matching readings in a column called ReadingsByType. Then AddColumns computes aggregates against those nested tables. The result is a grouped summary collection — the equivalent of a GROUP BY with aggregates in SQL.
Warning: Nested tables from
GroupBycan't be easily displayed in standard galleries withoutUngroup-ing them first. Use this pattern for summary displays (cards, labels, charts) rather than trying to bind a grouped collection directly to a gallery expecting flat records.
While Patch is precise (one record at a time, by identity), UpdateIf and RemoveIf let you apply bulk operations across all records matching a condition — no looping required.
// Inspector marks all "Normal" readings in the current session as reviewed
UpdateIf(
colCurrentReadings,
Status = "Normal",
{ReviewedBy: varCurrentUser.displayName, ReviewedAt: Now(), IsReviewed: true}
)
This touches every record where Status = "Normal" in a single operation. The equivalent with ForAll and Patch would work but would be slower and more verbose.
// Clear out all submitted records from the local collection after sync
RemoveIf(colCurrentReadings, IsSubmitted = true)
Combined with Patch for the submission step, this gives you a clean post-sync pattern: Patch the unsubmitted records to the backend, mark them as submitted locally, then RemoveIf to clear them from the collection.
Now let's bring everything together into the pattern that started this lesson: an app that accumulates work locally and submits when connectivity is available.
Power Apps provides SaveData and LoadData for persisting collection data to the device's local storage:
// App.OnStart — load any previously saved work
LoadData(colCurrentReadings, "LocalReadingsCache", true);
LoadData(colOfflineQueue, "OfflineSubmissionQueue", true);
// After adding a new reading — save to device storage
Collect(colCurrentReadings, {
EquipmentID: txtScannedID.Text,
Reading: Value(txtReading.Text),
Status: "Pending",
Timestamp: Now(),
FacilityID: varSelectedFacility.ID,
IsSubmitted: false
});
SaveData(colCurrentReadings, "LocalReadingsCache");
The third parameter in LoadData (true) tells Power Apps to create the collection if it doesn't exist — preventing errors on first launch.
Warning:
SaveDataandLoadDataare only available in published apps running on mobile devices (iOS/Android) or Windows desktop. They do not work in the browser-based web player or in Power Apps Studio. Test your offline patterns on actual target devices.
// "Sync Now" button - OnSelect
If(
Connection.Connected,
// Attempt to patch all unsubmitted records to the backend
With(
{patchResult: Patch(
InspectionReadings,
Filter(colCurrentReadings, IsSubmitted = false)
)},
If(
IsEmpty(Errors(InspectionReadings)),
// Success path: mark records as submitted locally
UpdateIf(
colCurrentReadings,
IsSubmitted = false,
{IsSubmitted: true, SubmittedAt: Now()}
);
SaveData(colCurrentReadings, "LocalReadingsCache");
Notify(
CountIf(colCurrentReadings, IsSubmitted = true) & " readings synced successfully.",
NotificationType.Success
),
// Failure path: notify and leave records in queue
Notify(
"Sync failed. " & First(Errors(InspectionReadings)).Message,
NotificationType.Error
)
)
),
Notify("No connection available. Your work is saved locally.", NotificationType.Warning)
)
The Connection.Connected signal gives you real-time connectivity status. The Errors() function lets you inspect whether the Patch operation produced any backend errors. This is a production-grade pattern — it handles the happy path, the failure path, and the offline path explicitly.
Apply everything in this lesson by building a minimal but complete version of the inspection workflow described throughout.
What you're building: A two-screen app where an inspector selects a facility, enters equipment readings (which accumulate in a local collection), can edit or remove any reading before submitting, and submits the batch to a SharePoint list.
Setup: Create a SharePoint list called EquipmentReadings with columns: Title (Equipment ID), Reading (Number), Status (Choice: Normal/Flagged), FacilityID (Text), InspectorName (Text), Timestamp (Date/Time), IsSubmitted (Yes/No).
Screen 1 — Facility Selection:
In the App OnStart:
ClearCollect(colFacilities, {ID: "F001", Name: "North Substation"}, {ID: "F002", Name: "South Substation"});
ClearCollect(colCurrentReadings, {EquipmentID: "", Reading: 0, Status: "", FacilityID: "", IsSubmitted: false});
Clear(colCurrentReadings);
LoadData(colCurrentReadings, "LocalReadingsCache", true);
Add a vertical gallery bound to colFacilities. Each row's OnSelect:
Set(varSelectedFacility, ThisItem);
Navigate(Screen2, ScreenTransition.Slide);
Screen 2 — Reading Entry and Submission:
On Screen2.OnVisible:
ClearCollect(
colSessionReadings,
Filter(colCurrentReadings, FacilityID = varSelectedFacility.ID And IsSubmitted = false)
);
Add text inputs for Equipment ID, Reading value, and a dropdown for Status. "Add Reading" button OnSelect:
Collect(colCurrentReadings, {
EquipmentID: txtEquipID.Text,
Reading: Value(txtReading.Text),
Status: ddStatus.Selected.Value,
FacilityID: varSelectedFacility.ID,
InspectorName: User().FullName,
Timestamp: Now(),
IsSubmitted: false
});
SaveData(colCurrentReadings, "LocalReadingsCache");
ClearCollect(colSessionReadings, Filter(colCurrentReadings, FacilityID = varSelectedFacility.ID And IsSubmitted = false));
Reset(txtEquipID); Reset(txtReading);
Add a gallery bound to colSessionReadings. Include a delete icon whose OnSelect is:
Remove(colCurrentReadings, ThisItem);
SaveData(colCurrentReadings, "LocalReadingsCache");
ClearCollect(colSessionReadings, Filter(colCurrentReadings, FacilityID = varSelectedFacility.ID And IsSubmitted = false));
"Submit All" button OnSelect:
If(
CountIf(colSessionReadings, true) > 0,
Patch(
EquipmentReadings,
RenameColumns(
ShowColumns(colSessionReadings, "EquipmentID", "Reading", "Status", "FacilityID", "InspectorName", "Timestamp"),
"EquipmentID", "Title"
)
);
If(
IsEmpty(Errors(EquipmentReadings)),
UpdateIf(colCurrentReadings, FacilityID = varSelectedFacility.ID And IsSubmitted = false, {IsSubmitted: true});
SaveData(colCurrentReadings, "LocalReadingsCache");
ClearCollect(colSessionReadings, Filter(colCurrentReadings, FacilityID = varSelectedFacility.ID And IsSubmitted = false));
Notify("Submitted " & CountIf(colCurrentReadings, IsSubmitted = true) & " readings.", NotificationType.Success),
Notify("Submission failed: " & First(Errors(EquipmentReadings)).Message, NotificationType.Error)
),
Notify("Nothing to submit.", NotificationType.Warning)
)
Test the app by adding several readings, closing and reopening it (on mobile) to verify persistence, then submitting and checking your SharePoint list.
Mistake 1: Schema drift from inconsistent record shapes
If you Collect records with different column sets at different times, your collection develops columns with blank values in unexpected places. The fix: always define a canonical record shape in your initial ClearCollect, then make sure every Collect and Patch uses the same columns. Use a template record pattern:
// Define the shape once
Set(varReadingTemplate, {
EquipmentID: "", Reading: 0, Status: "Pending",
FacilityID: "", InspectorName: "", Timestamp: Now(), IsSubmitted: false
});
// Use Patch with the template as base to guarantee full schema
Patch(colCurrentReadings, Defaults(colCurrentReadings), Patch(varReadingTemplate, {
EquipmentID: txtEquipID.Text,
Reading: Value(txtReading.Text)
}))
Mistake 2: Assuming Patch creates a duplicate instead of updating
Patch with ThisItem as the BaseRecord relies on record identity — the internal record pointer that Power Apps maintains for records in a collection. This works perfectly when ThisItem comes from a gallery bound to that same collection. But if you copy a record between collections or reconstruct a record from scratch, you lose the identity pointer and Patch will insert instead of update. When you need explicit upsert by business key (like EquipmentID), use LookUp as the BaseRecord.
Mistake 3: Large collections and performance
Collections with thousands of records start showing sluggishness, especially when combined with AddColumns and LookUp inside galleries. Symptoms: gallery scrolling lags, OnVisible takes several seconds. Solutions: filter aggressively before loading (don't ClearCollect an entire enterprise table), use delegation for the initial data pull from the source, and avoid LookUp inside gallery item formulas — pre-compute enriched collections instead.
Mistake 4: Forgetting to handle Patch errors from the backend
A Patch to SharePoint or Dataverse can fail for many reasons: permission issues, required fields missing, column type mismatches. If you don't check Errors() after the Patch call, your user gets no feedback and your data is silently not saved. Always wrap backend Patch calls in error-checking logic.
Mistake 5: Calling ClearCollect in a property that fires repeatedly
Putting ClearCollect directly in a gallery's Items property or a label's Text property means it fires every time the gallery renders. This hammers your data source with repeated queries. Collections that seed other UI elements should be populated once, in OnVisible or OnStart, not inline in a display property.
You've covered the full arc of local data management in Canvas Apps: from understanding what collections actually are, through the nuanced differences between ClearCollect, Collect, and Clear, to the upsert mechanics of Patch, the bulk power of UpdateIf and RemoveIf, simulated relational operations on in-memory tables, and a production-grade offline submission pipeline.
The mental model to carry forward: treat your collections as a deliberate data layer, not a convenience tool. Define their schemas explicitly. Initialize them intentionally. Commit to backends with error handling. Refresh after writes. When you treat them this way, collections let you build Canvas Apps that are fast, resilient, and genuinely capable of handling the complexity that real workflows demand.
Where to go next:
SaveData/LoadData handle, learn how model-driven apps and Dataverse mobile handle true sync with conflict resolutionLearning Path: Canvas Apps 101