Skip to content

Commit 6b41e45

Browse files
committed
feat: add support for stale-while-revalidate pattern with new IsFetching and IsRefetching properties in AsyncData
1 parent f30c5fb commit 6b41e45

9 files changed

Lines changed: 522 additions & 15 deletions

File tree

README.md

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -334,6 +334,31 @@ public record UserState(AsyncData<User> CurrentUser);
334334
@if (State.CurrentUser.HasError) { <p class="error">@State.CurrentUser.Error</p> }
335335
```
336336

337+
### Background Refetch (Stale-While-Revalidate)
338+
339+
Show existing data while refreshing in the background:
340+
341+
```csharp
342+
// Transition to loading while preserving existing data
343+
await UpdateAsync(s => s with { User = s.User.ToLoadingPreserved() });
344+
345+
// In component - show loading overlay on existing content
346+
@if (State.User.IsRefetching)
347+
{
348+
<div class="overlay"><Spinner /></div>
349+
}
350+
@if (State.User.HasData)
351+
{
352+
<UserCard User="@State.User.Data" />
353+
}
354+
```
355+
356+
| Property | Description |
357+
|----------|-------------|
358+
| `IsFetching` | True during both initial loads and background refetches |
359+
| `IsRefetching` | True when data exists AND a refetch is in progress |
360+
| `ToLoadingPreserved()` | Transitions to loading while keeping existing `Data` |
361+
337362
### ExecuteAsync - Structured Async Flow
338363

339364
```csharp

docs-site/async-helpers/async-data.html

Lines changed: 47 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -228,9 +228,15 @@ <h2>Understanding the States</h2>
228228
<tr>
229229
<td><code>Loading</code></td>
230230
<td>Request in progress</td>
231-
<td><code>HasData = false</code><br><code>IsLoading = true</code><br><code>HasError = false</code></td>
231+
<td><code>HasData = false</code><br><code>IsLoading = true</code><br><code>IsFetching = true</code><br><code>HasError = false</code></td>
232232
<td><code>state.User.ToLoading()</code></td>
233233
</tr>
234+
<tr>
235+
<td><code>Refetching</code></td>
236+
<td>Background refresh with existing data</td>
237+
<td><code>HasData = true</code><br><code>IsLoading = false</code><br><code>IsFetching = true</code><br><code>IsRefetching = true</code></td>
238+
<td><code>state.User.ToLoadingPreserved()</code></td>
239+
</tr>
234240
<tr>
235241
<td><code>Success</code></td>
236242
<td>Data loaded successfully</td>
@@ -316,11 +322,16 @@ <h3>Creating AsyncData&lt;T&gt;</h3>
316322
<pre><code class="language-csharp">// Initial state (no request made)
317323
var data = AsyncData&lt;User&gt;.NotAsked();
318324

319-
// Loading state
325+
// Loading state (clears existing data)
320326
var loading = data.ToLoading();
321327
// Or explicitly
322328
var loading = AsyncData&lt;User&gt;.Loading();
323329

330+
// Loading state preserving existing data (stale-while-revalidate)
331+
var refetching = data.ToLoadingPreserved();
332+
// If data.HasData: IsRefetching = true, Data preserved
333+
// If no data: behaves like ToLoading()
334+
324335
// Success state with data
325336
var success = AsyncData&lt;User&gt;.Success(user);
326337

@@ -332,15 +343,17 @@ <h3>Checking State</h3>
332343
<div class="code-block">
333344
<pre><code class="language-csharp">// Check state
334345
bool isNotAsked = data.IsNotAsked;
335-
bool isLoading = data.IsLoading;
346+
bool isLoading = data.IsLoading; // True only for initial load (no data yet)
347+
bool isFetching = data.IsFetching; // True during both initial load and refetch
348+
bool isRefetching = data.IsRefetching; // True when HasData &amp;&amp; IsFetching
336349
bool hasData = data.HasData;
337350
bool hasError = data.HasError;
338351

339352
// Access data (only when HasData is true)
340353
User user = data.Data!; // Nullable, use with HasData check
341354

342355
// Access error message (only when HasError is true)
343-
string error = data.Error; // Empty string if no error</code></pre>
356+
string? error = data.Error; // null if no error</code></pre>
344357
</div>
345358

346359
<h3>Pattern Matching</h3>
@@ -400,11 +413,11 @@ <h3>2. Retry on Error</h3>
400413
}</code></pre>
401414
</div>
402415

403-
<h3>3. Refresh Data</h3>
416+
<h3>3. Refresh Data (Clear Current)</h3>
404417
<div class="code-block">
405418
<pre><code class="language-csharp">async Task Refresh()
406419
{
407-
// Set to loading (shows spinner even if data exists)
420+
// Set to loading (clears current data, shows full spinner)
408421
await Update(s =&gt; s with { User = s.User.ToLoading() });
409422

410423
// Reload data
@@ -415,7 +428,34 @@ <h3>3. Refresh Data</h3>
415428
}</code></pre>
416429
</div>
417430

418-
<h3>4. Multiple Async Fields</h3>
431+
<h3>4. Background Refresh (Stale-While-Revalidate)</h3>
432+
<div class="code-block">
433+
<pre><code class="language-csharp">// Show existing data while refreshing in the background
434+
async Task BackgroundRefresh()
435+
{
436+
// Start refetch - keeps existing data visible
437+
await Update(s =&gt; s with { User = s.User.ToLoadingPreserved() });
438+
439+
// Reload data
440+
await ExecuteAsync(
441+
() =&gt; UserService.GetUserAsync(userId),
442+
success: (s, user) =&gt; s with { User = AsyncData.Success(user) },
443+
error: (s, ex) =&gt; s with { User = s.User.ToFailure(ex.Message) }
444+
);
445+
}
446+
447+
// Component - show loading overlay on existing content
448+
@if (State.User.IsRefetching)
449+
{
450+
&lt;div class="loading-overlay"&gt;&lt;Spinner /&gt;&lt;/div&gt;
451+
}
452+
@if (State.User.HasData)
453+
{
454+
&lt;UserCard User="@State.User.Data" /&gt;
455+
}</code></pre>
456+
</div>
457+
458+
<h3>5. Multiple Async Fields</h3>
419459
<div class="code-block">
420460
<pre><code class="language-csharp">public record DashboardState(
421461
AsyncData&lt;User&gt; User,

docs-site/async-helpers/index.html

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -186,15 +186,19 @@ <h3>UpdateDebounced - Debounced Search</h3>
186186
<div class="step-number">2</div>
187187
<div class="step-content">
188188
<h3>AsyncData&lt;T&gt; - Simple Async State</h3>
189-
<p>Replaces 20+ lines of loading/data/error state management with a single property.</p>
189+
<p>Replaces 20+ lines of loading/data/error state management with a single property. Supports stale-while-revalidate pattern for background refreshes.</p>
190190
<div class="code-block">
191191
<pre><code class="language-csharp">// State definition
192192
public record UserState(AsyncData&lt;User&gt; User);
193193

194194
// Component usage
195195
@if (State.User.IsLoading) { &lt;p&gt;Loading...&lt;/p&gt; }
196+
@if (State.User.IsRefetching) { &lt;div class="overlay"&gt;&lt;Spinner /&gt;&lt;/div&gt; }
196197
@if (State.User.HasData) { &lt;p&gt;@State.User.Data.Name&lt;/p&gt; }
197-
@if (State.User.HasError) { &lt;p&gt;@State.User.Error&lt;/p&gt; }</code></pre>
198+
@if (State.User.HasError) { &lt;p&gt;@State.User.Error&lt;/p&gt; }
199+
200+
// Background refresh - keeps existing data visible
201+
await Update(s =&gt; s with { User = s.User.ToLoadingPreserved() });</code></pre>
198202
</div>
199203
<p><a href="./async-data.html">Learn more about AsyncData&lt;T&gt; →</a></p>
200204
</div>

samples/EasyAppDev.Blazor.Store.Sample/Pages/AsyncDataDemo.razor

Lines changed: 80 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -65,7 +65,19 @@
6565
}
6666
else if (State.User.HasData)
6767
{
68-
<div class="user-profile">
68+
<div class="user-profile position-relative">
69+
@if (State.User.IsRefetching)
70+
{
71+
<div class="position-absolute top-0 start-0 w-100 h-100 d-flex align-items-center justify-content-center"
72+
style="background: rgba(255,255,255,0.8); z-index: 10;">
73+
<div class="text-center">
74+
<div class="spinner-border text-info" role="status">
75+
<span class="visually-hidden">Refreshing...</span>
76+
</div>
77+
<p class="mt-2 mb-0 text-info fw-bold">Refreshing...</p>
78+
</div>
79+
</div>
80+
}
6981
<div class="text-center mb-4">
7082
<img src="@State.User.Data!.Avatar" alt="@State.User.Data.Name"
7183
class="rounded-circle" width="128" height="128" />
@@ -90,18 +102,27 @@
90102
</div>
91103
</div>
92104

93-
<div class="mt-3 d-flex gap-2">
94-
<button class="btn btn-primary" @onclick="() => LoadUser(State.CurrentUserId)" disabled="@State.User.IsLoading">
105+
<div class="mt-3 d-flex gap-2 flex-wrap">
106+
<button class="btn btn-primary" @onclick="() => LoadUser(State.CurrentUserId)" disabled="@State.User.IsFetching">
95107
Load User @State.CurrentUserId
96108
</button>
97-
<button class="btn btn-warning" @onclick="SimulateError" disabled="@State.User.IsLoading">
109+
<button class="btn btn-info" @onclick="BackgroundRefresh" disabled="@(!State.User.HasData || State.User.IsFetching)">
110+
Background Refresh
111+
</button>
112+
<button class="btn btn-warning" @onclick="SimulateError" disabled="@State.User.IsFetching">
98113
Simulate Error (User 999)
99114
</button>
100115
<button class="btn btn-secondary" @onclick="Reset">
101116
Reset
102117
</button>
103118
</div>
104119

120+
<div class="alert alert-info mt-3" role="alert">
121+
<strong>Stale-While-Revalidate:</strong> Click "Background Refresh" after loading a user.
122+
The current profile stays visible while new data loads in the background.
123+
Watch <code>IsRefetching</code> turn true!
124+
</div>
125+
105126
<div class="card mt-4">
106127
<div class="card-header">
107128
<h5>Code Comparison</h5>
@@ -172,6 +193,23 @@ State.User.Error</code></pre>
172193
<span class="badge @(State.User.IsLoading ? "bg-primary" : "bg-secondary")">
173194
@State.User.IsLoading
174195
</span>
196+
<small class="text-muted d-block">Initial load only</small>
197+
</dd>
198+
199+
<dt>IsFetching</dt>
200+
<dd>
201+
<span class="badge @(State.User.IsFetching ? "bg-info" : "bg-secondary")">
202+
@State.User.IsFetching
203+
</span>
204+
<small class="text-muted d-block">Any fetch (initial or refetch)</small>
205+
</dd>
206+
207+
<dt>IsRefetching</dt>
208+
<dd>
209+
<span class="badge @(State.User.IsRefetching ? "bg-warning" : "bg-secondary")">
210+
@State.User.IsRefetching
211+
</span>
212+
<small class="text-muted d-block">HasData + IsFetching</small>
175213
</dd>
176214

177215
<dt>HasData</dt>
@@ -216,11 +254,16 @@ State.User.Error</code></pre>
216254
<h5 class="mb-0">State Transitions</h5>
217255
</div>
218256
<div class="card-body small">
219-
<pre class="bg-light p-2 mb-0"><code>// Start loading
257+
<pre class="bg-light p-2 mb-0"><code>// Start loading (clears data)
220258
Update(s => s with {
221259
User = s.User.ToLoading()
222260
});
223261

262+
// Background refresh (keeps data)
263+
Update(s => s with {
264+
User = s.User.ToLoadingPreserved()
265+
});
266+
224267
// Success
225268
Update(s => s with {
226269
User = AsyncData&lt;T&gt;.Success(data)
@@ -275,6 +318,38 @@ Update(s => s with {
275318
}
276319
}
277320

321+
async Task BackgroundRefresh()
322+
{
323+
// Use ToLoadingPreserved() - keeps existing data visible while refetching
324+
// This enables the "stale-while-revalidate" pattern
325+
Update(s => s with { User = s.User.ToLoadingPreserved() });
326+
327+
try
328+
{
329+
// Simulate network delay to see the overlay
330+
await Task.Delay(1500);
331+
332+
var user = await Api.GetUserAsync(State.CurrentUserId);
333+
334+
if (user != null)
335+
{
336+
var profileData = new UserProfileData(
337+
Id: user.Id,
338+
Name: user.Name,
339+
Email: user.Email,
340+
Avatar: user.AvatarUrl,
341+
Bio: user.Bio,
342+
LoadedAt: DateTime.Now
343+
);
344+
Update(s => s with { User = AsyncData<UserProfileData>.Success(profileData) });
345+
}
346+
}
347+
catch (Exception ex)
348+
{
349+
Update(s => s with { User = AsyncData<UserProfileData>.Failure(ex.Message) });
350+
}
351+
}
352+
278353
async Task SimulateError()
279354
{
280355
// Try to load invalid user (will return null from API)

src/EasyAppDev.Blazor.Store/AsyncActions/AsyncData.cs

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,20 @@ public record AsyncData<T>
5858
/// </summary>
5959
public string? Error { get; init; }
6060

61+
/// <summary>
62+
/// Gets a value indicating whether a fetch operation is in progress.
63+
/// Unlike <see cref="IsLoading"/>, this is true during both initial loads and background refetches.
64+
/// Use this to show loading indicators while preserving existing data.
65+
/// </summary>
66+
public bool IsFetching { get; init; }
67+
68+
/// <summary>
69+
/// Gets a value indicating whether a background refetch is in progress while data is available.
70+
/// True when both <see cref="HasData"/> and <see cref="IsFetching"/> are true.
71+
/// Use this to show a loading overlay on existing content.
72+
/// </summary>
73+
public bool IsRefetching => HasData && IsFetching;
74+
6175
private AsyncData() { }
6276

6377
/// <summary>
@@ -82,6 +96,7 @@ private AsyncData() { }
8296
{
8397
IsNotAsked = false,
8498
IsLoading = true,
99+
IsFetching = true,
85100
HasData = false,
86101
HasError = false,
87102
Data = default,
@@ -130,6 +145,35 @@ public static AsyncData<T> Failure(string error)
130145
/// <returns>A new AsyncData in the Loading state.</returns>
131146
public AsyncData<T> ToLoading() => Loading();
132147

148+
/// <summary>
149+
/// Transitions to a loading state while preserving existing data.
150+
/// Use this for background refetches where you want to show a loading indicator
151+
/// without clearing the current content (stale-while-revalidate pattern).
152+
/// </summary>
153+
/// <returns>
154+
/// A new AsyncData with <see cref="IsFetching"/> set to true.
155+
/// If data exists, <see cref="HasData"/> and <see cref="Data"/> are preserved,
156+
/// and <see cref="IsRefetching"/> will be true.
157+
/// If no data exists, behaves like <see cref="ToLoading"/>.
158+
/// </returns>
159+
/// <example>
160+
/// <code>
161+
/// // In a component updating existing data:
162+
/// await Update(s => s with { User = s.User.ToLoadingPreserved() });
163+
/// // User.IsRefetching is true, User.Data still available for display
164+
/// </code>
165+
/// </example>
166+
public AsyncData<T> ToLoadingPreserved() => new()
167+
{
168+
IsNotAsked = false,
169+
IsLoading = !HasData, // Only "loading" if no prior data
170+
IsFetching = true, // Always fetching
171+
HasData = HasData, // Preserve
172+
HasError = false,
173+
Data = Data, // Preserve
174+
Error = null
175+
};
176+
133177
/// <summary>
134178
/// Transitions this AsyncData to the Success state with data.
135179
/// </summary>

src/EasyAppDev.Blazor.Store/AsyncActions/AsyncDataExtensions.cs

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -54,4 +54,26 @@ public static AsyncData<T> ToSuccess<T>(this AsyncData<T> self, T data)
5454
/// </example>
5555
public static AsyncData<T> ToFailure<T>(this AsyncData<T> self, string error)
5656
=> AsyncData<T>.Failure(error);
57+
58+
/// <summary>
59+
/// Transitions to a loading state while preserving existing data.
60+
/// Use this for background refetches where you want to show a loading indicator
61+
/// without clearing the current content (stale-while-revalidate pattern).
62+
/// </summary>
63+
/// <typeparam name="T">The type of data.</typeparam>
64+
/// <param name="self">The current AsyncData.</param>
65+
/// <returns>
66+
/// A new AsyncData with IsFetching set to true.
67+
/// If data exists, HasData and Data are preserved, and IsRefetching will be true.
68+
/// If no data exists, behaves like ToLoading.
69+
/// </returns>
70+
/// <example>
71+
/// <code>
72+
/// // In state method for background refresh
73+
/// return this with { User = this.User.ToLoadingPreserved() };
74+
/// // User.IsRefetching is true, User.Data still available for display
75+
/// </code>
76+
/// </example>
77+
public static AsyncData<T> ToLoadingPreserved<T>(this AsyncData<T> self)
78+
=> self.ToLoadingPreserved();
5779
}

0 commit comments

Comments
 (0)