Deferred Save Pattern — In-Memory ViewModel Architecture¶
Overview¶
MatStream uses a deferred save model for complex editor dialogs. All changes are held in memory until the user explicitly clicks Save or Save and Close. Clicking Cancel discards everything. Nothing is written to the database during editing.
This pattern was first implemented for the Category Editor and should be applied consistently to all similar dialogs.
Core concepts¶
ViewModels live client-side only¶
ViewModels are C# classes that live in MatStream.Web.Assembly. They
are not DTOs — they are never serialized or sent to the API. They
exist only to hold in-memory editing state between the UI and the save
operation.
MatStream.Web.Assembly/Features/{Feature}/ViewModels/
{Entity}ViewModel.cs — wraps a DTO + dirty/added/deleted flags
{Entity}PermRowVm.cs — wraps one permission row
A typical ViewModel looks like this:
public sealed class CategoryPropViewModel
{
public CategoryPropDto Prop { get; init; } = new(); // the DTO
public bool IsDirty { get; set; } // field edits
public bool IsAdded { get; set; } // new, not yet POSTed
public bool IsDeleted { get; set; } // flagged for deletion
public bool PermsLoaded { get; set; } // lazy load flag
public List<PermRowVm> Perms { get; set; } = new(); // in-memory perms
public bool PermsDirty => Perms.Any(p => p.IsDirty || p.IsDeleted);
}
Flags and their meaning¶
| Flag | Set when | Cleared when |
|---|---|---|
IsDirty |
Any field on the row is edited | SaveAsync completes |
IsAdded |
Row added in memory, not yet POSTed | SaveAsync POSTs and gets real ID |
IsDeleted |
User removes a row | SaveAsync sends DELETE, then removes from list |
PermsLoaded |
Perms fetched from API for first time | Never reset (perms stay in memory) |
Permission rows (PermRowVm)¶
public sealed class PermRowVm
{
public int? PermId { get; set; } // null = new
public int WorkspaceGroupId { get; set; }
public string GroupName { get; set; } = string.Empty;
public bool CanView { get; set; }
public bool CanEdit { get; set; }
public bool IsDeleted { get; set; }
public bool IsDirty { get; set; }
}
Component architecture¶
Ownership¶
The tab panel component owns the VM list and all API calls:
Dialog
└── Panel (hosts tab strip)
├── Tab: General → direct field binding, no VMs needed
├── Tab: {List A} → Tab Panel owns List<{A}ViewModel>
│ ├── Grid (data-down, @ref)
│ └── Perm grid (data-down via Rows param)
└── Tab: {List B} → Tab Panel owns List<{B}ViewModel>
├── Grid (data-down, @ref)
└── Perm grid (data-down via Rows param)
Data flow — downward only¶
Tab panels pass their VM lists down to grids as [Parameter].
Grids are pure rendering components — they never own or fetch data.
<MyGrid ViewModels="@_viewModels"
SelectedChanged="OnSelectedAsync"
OnDirty="OnGridDirtyAsync" />
Data flow — upward via callbacks¶
Grids fire callbacks for user actions:
| Callback | When fired | What the panel does |
|---|---|---|
SelectedChanged |
Row clicked | Sets _selectedVm, lazy-loads perms if needed |
OnDirty |
Field edited or row dropped | Raises OnDirtyChanged to dialog |
AddRequested |
+ button clicked | Panel opens picker, adds VMs to list |
RemoveRequested |
× button clicked | Panel sets IsDeleted or removes from list |
Perm grids — write-back via SaveAllAsync¶
Perm grids (CategoryPropertyPermGrid, CategoryTabPermGrid) wrap
WorkspaceGroupPermGrid and use its SaveAllAsync callback to write
rows back to the parent VM immediately on every change — still
without hitting the API:
User ticks checkbox
→ WorkspaceGroupPermGrid.OnToggleAsync
→ NotifyDirtyAsync(true)
→ OnPermDirtyChanged(true)
→ _permGrid.SaveAsync() ← flushes internal _rows
→ SaveAllPermsAsync(rows, ct) ← our callback
→ RowsChanged.InvokeAsync(updated) ← writes back to VM.Perms
→ OnPermRowsChangedAsync ← panel updates _selectedVm.Perms
Lifecycle — how loading works¶
The render loop problem¶
Blazor's OnAfterRenderAsync is called after every render — including
renders triggered by child component state changes. A naive load in
OnAfterRenderAsync can cause infinite loops.
The correct pattern¶
Use two separate guards:
private int? _loadedCategoryId; // set AFTER API call completes
private int? _loadTriggeredForId; // set IMMEDIATELY in OnParametersSet
protected override void OnParametersSet()
{
if (_loadTriggeredForId == CategoryId) return; // already triggered
_loadTriggeredForId = CategoryId; // prevent re-entry on next render
_loadedCategoryId = null; // will be set after API responds
_viewModels = new(); // reset to empty
_loadSeq++;
}
protected override async Task OnAfterRenderAsync(bool firstRender)
{
if (IsCancelled || _loading) return;
if (_loadedCategoryId == CategoryId || CategoryId is null or <= 0 || _grid is null) return;
_loading = true;
try
{
var vms = await FetchAsync(...);
_loadedCategoryId = CategoryId; // now mark as loaded
_viewModels = vms;
StateHasChanged(); // direct call — we're on render thread
await Task.Yield(); // let Syncfusion process new DataSource
if (_grid.ItemsCount > 0)
await _grid.SelectFirstAsync(ct);
}
finally { _loading = false; }
}
Why _loadTriggeredForId is needed¶
Without it, every render between "parameters set" and "API response
received" would reset _viewModels = new(), passing a new empty list
reference to the child grid, which triggers its OnParametersSet,
which triggers a re-render of the parent — an infinite loop.
Lazy perm loading¶
Perms are loaded on first selection, not on initial load:
private async Task OnSelectedAsync(MyViewModel? vm)
{
_selectedVm = vm;
if (vm is not null && !vm.PermsLoaded)
{
if (vm.EntityId > 0)
await LoadPermsForVmAsync(vm);
else
vm.PermsLoaded = true; // new unsaved row — no perms yet
}
// Only call StateHasChanged after perm load — avoids re-rendering
// the left grid (which resets Syncfusion selection state)
if (vm is not null && !vm.PermsLoaded) // was false before load
await InvokeAsync(StateHasChanged);
else
StateHasChanged();
}
Grid component rules¶
DataSource must be a stable reference¶
Syncfusion loses selection state when DataSource changes reference.
Cache the visible list — only rebuild when the parent list reference
actually changes:
private List<MyViewModel> _visibleViewModels = new();
private List<MyViewModel>? _lastViewModels;
protected override void OnParametersSet()
{
if (!ReferenceEquals(_lastViewModels, ViewModels))
{
_lastViewModels = ViewModels;
_visibleViewModels = ViewModels.Where(v => !v.IsDeleted).ToList();
}
}
Bind to the cached list:
<SfGrid DataSource="@_visibleViewModels" ... />
Force rebuild after remove¶
When IsDeleted is set on a VM inside the list (same list reference),
ReferenceEquals will still return true and _visibleViewModels won't
rebuild. Force a rebuild by nulling the cache:
// In parent, after setting vm.IsDeleted = true:
_grid?.NotifyViewModelsChanged();
// In grid:
public void NotifyViewModelsChanged() => _lastViewModels = null;
Never call StateHasChanged inside OnParametersSetAsync¶
Blazor schedules a re-render automatically after OnParametersSetAsync
completes. Calling it manually schedules a second render, which
triggers OnParametersSetAsync again — a loop:
// WRONG:
protected override async Task OnParametersSetAsync()
{
if (ReferenceEquals(_loadedRows, Rows)) return;
_loadedRows = Rows;
await InvokeAsync(StateHasChanged); // ← causes loop
if (_permGrid is not null)
await _permGrid.ReloadAsync();
}
// CORRECT:
protected override async Task OnParametersSetAsync()
{
if (ReferenceEquals(_loadedRows, Rows)) return;
_loadedRows = Rows;
if (_permGrid is not null)
await _permGrid.ReloadAsync();
}
Use StateHasChanged() directly in OnAfterRenderAsync¶
Inside OnAfterRenderAsync you are already on the render thread.
Use StateHasChanged() directly — InvokeAsync(StateHasChanged) adds
an extra dispatch cycle that can cause an additional OnAfterRenderAsync
call:
// WRONG:
await InvokeAsync(StateHasChanged);
// CORRECT:
StateHasChanged();
SelectRowAsync fires OnRowSelected — don't double-fire¶
_sfGrid.SelectRowAsync(0) fires the Syncfusion RowSelected event,
which fires your SelectedPropertyChanged callback. Don't also call
the callback directly in SelectFirstAsync — that fires it twice:
public async Task SelectFirstAsync(CancellationToken ct = default)
{
var first = ViewModels.FirstOrDefault();
SelectedVm = first;
if (_sfGrid is not null && first is not null)
await _sfGrid.SelectRowAsync(0); // fires RowSelected → callback
else
if (SelectedChanged.HasDelegate)
await SelectedChanged.InvokeAsync(first); // only if no grid
}
SaveAsync order¶
When implementing SaveAsync on a tab panel, follow this order to
ensure foreign keys exist before they are referenced:
- Structural changes first — POST new assignments (get real IDs back)
- Field updates — PATCH dirty rows (requires real IDs from step 1)
- Deletions — DELETE removed assignments
- Permissions — upsert/delete perm rows (requires real IDs from step 1)
- Cleanup —
RemoveAll(v => v.IsDeleted)from the VM list - Signal dirty cleared —
_ = OnDirtyChanged.InvokeAsync(false)
Example:
public async Task SaveAsync(CancellationToken ct = default)
{
if (ReadOnly) return;
// 1. POST new assignments — get real IDs back
foreach (var vm in _viewModels.Where(v => v.IsAdded && !v.IsDeleted))
{
var newId = await Service.AssignAsync(parentId, vm.Entity.DisplayId, ct);
vm.Entity.Id = newId;
vm.IsAdded = false;
}
// 2. PATCH dirty field changes
foreach (var vm in _viewModels.Where(v => v.IsDirty && !v.IsDeleted && !v.IsAdded && v.Entity.Id > 0))
{
await Service.UpdateAsync(vm.Entity.Id, vm.Entity, ct);
vm.IsDirty = false;
}
// 3. DELETE removed assignments
foreach (var vm in _viewModels.Where(v => v.IsDeleted && !v.IsAdded && v.Entity.Id > 0))
await Service.RemoveAsync(parentId, vm.Entity.Id, ct);
// 4. Save permissions for surviving rows
foreach (var vm in _viewModels.Where(v => !v.IsDeleted && v.PermsLoaded && v.PermsDirty && v.Entity.Id > 0))
{
foreach (var perm in vm.Perms.Where(p => p.IsDeleted && p.PermId is > 0))
await Service.DeletePermAsync(perm.PermId!.Value, ct);
foreach (var perm in vm.Perms.Where(p => p.IsDirty && !p.IsDeleted))
{
var saved = await Service.UpsertPermAsync(vm.Entity.Id, perm, ct);
if (saved is not null) { perm.PermId = saved.Id; perm.IsDirty = false; }
}
vm.Perms.RemoveAll(p => p.IsDeleted);
}
// 5. Remove deleted VMs from the in-memory list
_viewModels.RemoveAll(v => v.IsDeleted);
// 6. Signal dirty cleared
_ = OnDirtyChanged.InvokeAsync(false);
}
RevertAsync¶
Cancel discards all in-memory state and reloads:
public async Task RevertAsync()
{
_loadedId = null;
_loadTriggeredForId = null;
_selectedVm = null;
_viewModels = new();
_loadSeq++;
_ = OnDirtyChanged.InvokeAsync(false);
await InvokeAsync(StateHasChanged);
}
The panel's OnAfterRenderAsync will then see _loadedId != CategoryId
and trigger a fresh load from the API.
Checklist for new features¶
When implementing this pattern for a new feature:
- [ ] Create
{Entity}ViewModel.cswithIsDirty,IsAdded,IsDeleted,PermsLoaded,Perms - [ ] Create
PermRowVm.cs(or reuse the shared one from Categories/ViewModels) - [ ] Tab panel owns
List<{Entity}ViewModel> _viewModels - [ ] Tab panel has
_loadedId+_loadTriggeredForIdguards - [ ] Grid is data-down:
[Parameter] List<{Entity}ViewModel> ViewModels - [ ] Grid caches
_visibleViewModelswithReferenceEqualsguard - [ ] Grid has
NotifyViewModelsChanged()for force-rebuild after delete - [ ] Grid fires
OnDirtycallback on field edit and row drop - [ ] Perm grid uses
SaveAllAsync+RowsChangedto write back in memory - [ ]
OnPermDirtyChangedcallsawait _permGrid.SaveAsync()before raising event - [ ]
SaveAsyncfollows the structural → field → delete → perms order - [ ]
RevertAsyncresets all flags and reloads - [ ] Dialog has Cancel (calls
RevertAsync), Save, Save and Close buttons - [ ] Save buttons disabled when
!IsDirty - [ ] Save clears dirty state and calls
OnDirtyChanged.InvokeAsync(false)