|
| 1 | +# Optimistic Mutations Guide |
| 2 | + |
| 3 | +This guide describes the BookStore frontend pattern for optimistic UI updates using `ReactiveQuery<T>.MutateAsync(...)` and `CatalogService`. |
| 4 | + |
| 5 | +## Why this Pattern Exists |
| 6 | + |
| 7 | +Optimistic UI gives immediate feedback while a mutation is in-flight. Without a reusable pattern, every component must re-implement: |
| 8 | + |
| 9 | +1. snapshot current state |
| 10 | +2. apply optimistic state |
| 11 | +3. call API |
| 12 | +4. rollback on failure |
| 13 | +5. handle success/error notifications |
| 14 | + |
| 15 | +BookStore now centralizes this flow so components stay thin and consistent. |
| 16 | + |
| 17 | +## Architecture |
| 18 | + |
| 19 | +The optimistic mutation stack has three layers: |
| 20 | + |
| 21 | +1. **ReactiveQuery** (`src/BookStore.Web/Services/ReactiveQuery.cs`) |
| 22 | + - Owns query state (`Data`, loading, errors) |
| 23 | + - Provides `MutateAsync` for generic optimistic lifecycle |
| 24 | +2. **CatalogService** (`src/BookStore.Web/Services/CatalogService.cs`) |
| 25 | + - Encodes domain-specific optimistic transforms (favorite, rating, soft delete, restore) |
| 26 | + - Calls Refit clients and displays snackbar feedback |
| 27 | +3. **Components** (`.razor` pages) |
| 28 | + - Trigger mutations by calling service methods with the active query |
| 29 | + - Do not implement rollback logic inline |
| 30 | + |
| 31 | +## Core Primitive: `ReactiveQuery<T>.MutateAsync` |
| 32 | + |
| 33 | +`MutateAsync` implements the reusable optimistic lifecycle: |
| 34 | + |
| 35 | +1. Store a snapshot of current `Data` |
| 36 | +2. Apply optimistic transform |
| 37 | +3. Execute server mutation |
| 38 | +4. On failure, restore snapshot and rethrow |
| 39 | + |
| 40 | +```csharp |
| 41 | +await query.MutateAsync( |
| 42 | + applyOptimistic: current => current with { IsFavorite = true }, |
| 43 | + mutation: ct => _booksClient.AddBookToFavoritesAsync(book.Id, cancellationToken: ct), |
| 44 | + cancellationToken: cancellationToken); |
| 45 | +``` |
| 46 | + |
| 47 | +## Service Pattern |
| 48 | + |
| 49 | +`CatalogService` should own mutation behavior and keep component code minimal. |
| 50 | + |
| 51 | +### Detail query (`ReactiveQuery<BookDto?>`) |
| 52 | + |
| 53 | +```csharp |
| 54 | +public async Task SoftDeleteBookAsync(BookDto book, ReactiveQuery<BookDto?> query, CancellationToken cancellationToken = default) |
| 55 | +{ |
| 56 | + try |
| 57 | + { |
| 58 | + await query.MutateAsync( |
| 59 | + current => current == null ? null : current with { IsDeleted = true }, |
| 60 | + ct => _booksClient.SoftDeleteBookAsync(book.Id, book.ETag, ct), |
| 61 | + cancellationToken); |
| 62 | + |
| 63 | + _ = _snackbar.Add("Book deleted", Severity.Success); |
| 64 | + } |
| 65 | + catch (Exception ex) |
| 66 | + { |
| 67 | + Log.BookDeleteFailed(_logger, book.Id, ex); |
| 68 | + _ = _snackbar.Add($"Failed to delete book: {ex.Message}", Severity.Error); |
| 69 | + } |
| 70 | +} |
| 71 | +``` |
| 72 | + |
| 73 | +### List query (`ReactiveQuery<PagedListDto<BookDto>>`) |
| 74 | + |
| 75 | +For list updates, mutate only the matching row and return a new list DTO. |
| 76 | + |
| 77 | +```csharp |
| 78 | +await query.MutateAsync( |
| 79 | + currentList => |
| 80 | + { |
| 81 | + var items = currentList.Items.ToList(); |
| 82 | + var index = items.FindIndex(b => b.Id == book.Id); |
| 83 | + if (index != -1) |
| 84 | + { |
| 85 | + items[index] = items[index] with |
| 86 | + { |
| 87 | + IsFavorite = !originalState, |
| 88 | + LikeCount = originalState ? items[index].LikeCount - 1 : items[index].LikeCount + 1 |
| 89 | + }; |
| 90 | + } |
| 91 | + |
| 92 | + return new PagedListDto<BookDto>(items, currentList.PageNumber, currentList.PageSize, currentList.TotalItemCount); |
| 93 | + }, |
| 94 | + mutation: ct => _booksClient.AddBookToFavoritesAsync(book.Id, cancellationToken: ct), |
| 95 | + cancellationToken: cancellationToken); |
| 96 | +``` |
| 97 | + |
| 98 | +## Component Usage |
| 99 | + |
| 100 | +Components should pass the active query object and avoid custom rollback lambdas. |
| 101 | + |
| 102 | +```csharp |
| 103 | +if (bookQuery == null) return; |
| 104 | +await CatalogService.ToggleFavoriteAsync(book, bookQuery, _cts.Token); |
| 105 | +``` |
| 106 | + |
| 107 | +## SSE and Eventual Consistency |
| 108 | + |
| 109 | +Book projections are asynchronous. After delete/restore, a direct immediate `LoadAsync()` can read stale projection data. |
| 110 | + |
| 111 | +Preferred behavior: |
| 112 | + |
| 113 | +1. keep optimistic local state from `MutateAsync` |
| 114 | +2. let SSE-triggered invalidation refresh in background when projection catches up |
| 115 | + |
| 116 | +Avoid forcing immediate reload after delete/restore unless there is a strong consistency requirement. |
| 117 | + |
| 118 | +## Do / Don't |
| 119 | + |
| 120 | +```text |
| 121 | +✅ Use ReactiveQuery.MutateAsync for optimistic lifecycle |
| 122 | +✅ Keep mutation transforms in CatalogService (or domain service), not in components |
| 123 | +✅ Keep components as orchestration-only for UI actions |
| 124 | +✅ Let SSE invalidation reconcile eventual consistency |
| 125 | +
|
| 126 | +❌ Reintroduce setOptimistic/setRollback lambdas in .razor files |
| 127 | +❌ Reintroduce separate optimistic ghost-state cache for this flow |
| 128 | +❌ Immediately force LoadAsync() after async-projection mutations by default |
| 129 | +``` |
| 130 | + |
| 131 | +## Migration Checklist |
| 132 | + |
| 133 | +When converting older optimistic code: |
| 134 | + |
| 135 | +1. Replace inline `MutateData` + try/catch rollback with `query.MutateAsync` |
| 136 | +2. Move transform logic into service methods |
| 137 | +3. Update component calls to pass query (`CatalogService.XxxAsync(book, bookQuery, ct)`) |
| 138 | +4. Keep snackbar/logging in service catch blocks |
| 139 | +5. Validate with: |
| 140 | + |
| 141 | +```bash |
| 142 | +dotnet build BookStore.slnx |
| 143 | +dotnet test tests/BookStore.Web.Tests/ |
| 144 | +dotnet format BookStore.slnx --verify-no-changes |
| 145 | +``` |
0 commit comments