# Fetchling > A TanStack Query-inspired async data fetching and state management library for .NET and Blazor. Fetchling brings TanStack Query's battle-tested patterns to the .NET ecosystem: predictable loading/error/success states, automatic caching, stale-while-revalidate, background refetching, and mutations with lifecycle callbacks — all built on Rx.NET observables. NuGet packages: - `Fetchling` — metapackage pulling in `Fetchling.Core` + `Fetchling.Extensions.DependencyInjection` (the upcoming 3.0) - `Fetchling.Core` — always required - `Fetchling.Extensions.DependencyInjection` — `AddFetchling()` for Microsoft DI - `Fetchling.Blazor` — ``, ``, `` Razor components - `Fetchling.Blazor.DevTools` — `` live cache inspector (development only) - `Fetchling.Mvvm` — `QueryViewModel`/`InfiniteQueryViewModel`/`MutationViewModel` INotifyPropertyChanged bindings for MAUI, WPF, WinUI, UNO, and Avalonia Target framework: net10.0 (the upcoming 3.0 drops net9.0; the current stable release, 2.0.0, still targets net9.0 and net10.0). All code is in C# with `nullable enable`. Upgrading from 1.x? The migration guide at https://psachmann.github.io/fetchling/doc/migrating-to-v2.html lists every breaking change: query `TData` constrained to reference types, new `IQueryClient`/`IMutation` members, `AddFetchling` using `TryAdd`, automatic cache eviction, and telemetry unit/tag changes. These docs are also indexed on Context7 under the library ID `/psachmann/fetchling`; use `/psachmann/fetchling/v2.0.0` for the current stable release, since the unversioned ID tracks `main` and the upcoming 3.0. --- ## Core Concepts ### QueryKey `QueryKey` is an immutable, equality-comparable key that identifies a cache entry. It wraps an ordered list of `object` parts whose equality is determined by `SequenceEqual`. ```csharp // Create keys QueryKey.From("users") // simple QueryKey.From("users", 42) // parameterized — different entry per id QueryKey.From("users", 42, "posts") // compound/hierarchical // Sentinel for the uninitialized state QueryKey.Default // returned by IQuery.Key before SetArgs is called ``` `QueryKey.ToString()` returns parts joined by `:`, e.g. `users:42`. Use this in predicate-based invalidation. ### QueryState Immutable snapshot of a query's current state. Produced by `IQuery.State`. ```csharp public enum QueryStatus : byte { Idle, Fetching, Success, Failure } record QueryState { QueryStatus Status { get; } // current lifecycle status TData? CurrentData { get; } // data from the most recent successful fetch (null otherwise) TData? LastData { get; } // data carried forward from the previous successful fetch Exception? Error { get; } // exception from the most recent failed fetch (null otherwise) bool IsIdle { get; } bool IsFetching { get; } bool IsSuccess { get; } bool IsFailure { get; } bool HasData { get; } // CurrentData is not null bool HasError { get; } // Error is not null } ``` `LastData` persists across all subsequent state transitions (Fetching, Failure, Idle). It is the foundation of stale-while-revalidate: while a background refetch is in progress, `LastData` holds the previous value so you can keep rendering meaningful content. ### MutationState Immutable snapshot of a mutation's current state. ```csharp public enum MutationStatus : byte { Idle, Running, Success, Failure } record MutationState { MutationStatus Status { get; } TData? CurrentData { get; } // result of last successful execution Exception? Error { get; } // exception from last failed execution bool IsIdle { get; } bool IsRunning { get; } bool IsSuccess { get; } bool IsFailure { get; } bool HasData { get; } bool HasError { get; } } ``` Like `QueryState`, mutation state replays its current value to new subscribers, so a subscriber joining mid-run sees `Running` immediately. `Success` and `Failure` are terminal until the next `Execute`; only `Cancel()` returns a mutation to `Idle`. --- ## IQueryClient The central client. Owns the cache. All queries and mutations created through it share the same cache. ```csharp public interface IQueryClient : IDisposable { // Gets or creates a query. Queries with the same key share one cache entry. IQuery CreateQuery(QueryOptions options); // Gets or creates an infinite (paginated) query. Queries with the same key share one cache entry. IInfiniteQuery CreateInfiniteQuery( InfiniteQueryOptions options); // Creates a mutation. InvalidateKeys are wired automatically on success. IMutation CreateMutation(MutationOptions options); // Marks all matching cache entries as stale and triggers a refetch if they have subscribers. void Invalidate(QueryKey key); void Invalidate(Func predicate); } ``` ### Creating a client without DI ```csharp IQueryClient client = QueryClientFactory.Create( new QueryClientOptions { StaleTime = TimeSpan.FromMinutes(1) }, scheduler: null, // defaults to DefaultScheduler.Instance logger: null // defaults to NullLogger ); ``` ### QueryClientOptions (global defaults) ```csharp new QueryClientOptions { StaleTime = TimeSpan.Zero, // default: stale immediately CacheTime = TimeSpan.FromMinutes(5), // default: evict 5 min after last subscriber RefetchInterval = null, // default: no polling RetryHandler = new DefaultRetryHandler(), // default: no retry (single attempt) ExecutionMode = QueryExecutionMode.Csr, // Csr = Singleton, Ssr = Scoped } ``` All per-query and per-mutation options override these globals. --- ## IQuery Returned by `IQueryClient.CreateQuery`. Acts as a key-switching proxy over the cache: each `SetArgs` call derives a new `QueryKey` and switches the active cache entry. ```csharp public interface IQuery : IQuery, IDisposable { QueryKey Key { get; } // current key (QueryKey.Default until first SetArgs) TimeSpan CacheTime { get; } QueryState CurrentState { get; } // synchronous snapshot — no subscription needed // Push new args. Derives a new key, switches the cache entry, triggers Invalidate(). void SetArgs(TArgs args); // Write data directly into the cache as Success, bypassing any fetch. Marks entry as fresh. void SetData(TData data); // Enable/disable the query. When false, no fetches run even if invalidated. // Re-enabling triggers an immediate Invalidate() if there are subscribers. void SetEnabled(bool enabled); // Observables — all replay the latest value to new subscribers (BehaviorSubject semantics) IObservable> State { get; } // all state transitions IObservable Success { get; } // unwrapped data on each success IObservable Failure { get; } // exception on each failure IObservable> Settled { get; } // after each fetch regardless of outcome // Derived observable: applies selector to each success value, deduplicates by comparer IObservable Select(Func selector, IEqualityComparer? comparer = null); // Trigger an immediate fetch, bypassing stale-time checks void Refetch(); // Mark data as stale; fetch immediately if subscribers exist, else defer until first subscriber // No-op if data was fetched within the StaleTime window — use Refetch() to force void Invalidate(); // Cancel the current in-flight fetch; returns query to Idle; LastData is preserved void Cancel(); // Remove from cache without tearing down subscriptions (vs Dispose which does both) void Detach(); // Warm the cache for given args without subscribing. Skips if data is still fresh. Task PrefetchAsync(TArgs args, CancellationToken cancellationToken = default); } ``` ### QueryOptions ```csharp new QueryOptions { // Required KeyFactory = id => QueryKey.From("users", id), Fetcher = (id, ct) => httpClient.GetFromJsonAsync($"/api/users/{id}", ct)!, // Optional overrides (null = use global default) StaleTime = TimeSpan.FromMinutes(5), CacheTime = TimeSpan.FromMinutes(30), RefetchInterval = TimeSpan.FromSeconds(30), // poll while subscribers exist RetryHandler = new MyRetryHandler(), IsEnabled = true, // default; set false for conditional/lazy queries InitialData = null, // pre-populate cache before first fetch; always treated as stale // Comparer to suppress re-emissions when fetched data is structurally identical DataComparer = EqualityComparer.Default, } ``` ### Query lifecycle ``` [no args] → Idle → (SetArgs / Invalidate) → Fetching → Success └→ Failure ``` `Invalidate()` respects `StaleTime` (no-op within window). `Refetch()` always fetches. When invalidated with no active subscribers, fetch is deferred until the first subscriber joins. A new `SetArgs` while a fetch is in progress cancels the in-flight fetch (`.Switch()` semantics). --- ## IInfiniteQuery Returned by `IQueryClient.CreateInfiniteQuery`. Accumulates pages of data fetched sequentially with a **page parameter** (cursor, offset, or page number). Shares the same cache, deduplication, stale-while-revalidate, and invalidation guarantees as `IQuery`. ### PageParam A small discriminated union used by `GetNextPageParam` / `GetPreviousPageParam` to signal whether a next/previous page exists. Necessary because C# generic nullable (`TPageParam?`) is ambiguous for value types. ```csharp // Signal "no more pages" PageParam.None // Signal "next page param is 2" PageParam.Some(2) // Implicit conversion from the underlying type PageParam p = 2; // equivalent to Some(2) ``` ### InfiniteQueryOptions ```csharp new InfiniteQueryOptions, int> { // Required KeyFactory = id => ["items", id], Fetcher = (id, page, ct) => api.GetItemsAsync(id, page, ct), InitialPageParam = 1, GetNextPageParam = info => info.Page.Count < PageSize ? PageParam.None : PageParam.Some(info.PageParam + 1), // Optional GetPreviousPageParam = null, // omit for forward-only lists MaxPages = null, // unbounded by default; set to trim oldest pages StaleTime = null, CacheTime = null, RefetchInterval = null, RetryHandler = null, IsEnabled = true, DataComparer = null, InitialData = null, // pre-seed first page; query starts Success, refetches in background } ``` `info` in `GetNextPageParam` is `InfinitePageInfo` — it carries the boundary page and all loaded pages so you can derive the next cursor from the data itself: ```csharp GetNextPageParam = info => info.Page.LastOrDefault()?.CreatedAt is { } cursor ? PageParam.Some(cursor) : PageParam.None ``` ### IInfiniteQuery ```csharp public interface IInfiniteQuery : IQuery { InfiniteQueryState CurrentState { get; } void SetArgs(TArgs args); void SetEnabled(bool enabled); // Fetch the next page (no-op when HasNextPage is false or no pages loaded yet) void FetchNextPage(); // Fetch the previous page (no-op when HasPreviousPage is false or no GetPreviousPageParam) void FetchPreviousPage(); IObservable> State { get; } IObservable> Success { get; } // all pages after full settle IObservable Failure { get; } IObservable> Settled { get; } void Detach(); } ``` ### InfiniteQueryState ```csharp record InfiniteQueryState { QueryStatus Status { get; } // Idle | Fetching | Success | Failure IReadOnlyList Pages { get; } // all loaded pages, oldest first IReadOnlyList PageParams { get; } // PageParams[i] fetched Pages[i] Exception? Error { get; } bool HasNextPage { get; } bool HasPreviousPage { get; } bool IsFetchingNextPage { get; } // Status stays Success; existing pages remain visible bool IsFetchingPreviousPage { get; } // Status stays Success; existing pages remain visible bool IsIdle { get; } // Status == Idle bool IsFetching { get; } // Status == Fetching (full refetch of all pages) bool IsSuccess { get; } bool IsFailure { get; } bool HasData { get; } // Pages.Count > 0 bool HasError { get; } } ``` `Invalidate()` / `Refetch()` re-fetches **all** currently loaded pages sequentially (TanStack behavior), rebuilding `Pages` and `PageParams`. Existing pages are carried in the `Fetching` state for stale-while-revalidate display. ### Typical usage ```csharp public sealed class ItemService(IQueryClient client, IApi api) : IDisposable { private const int PageSize = 20; public readonly IInfiniteQuery, int> ItemsQuery = client.CreateInfiniteQuery(new InfiniteQueryOptions, int> { KeyFactory = listId => ["items", listId], Fetcher = (listId, page, ct) => api.GetItemsAsync(listId, page, PageSize, ct), InitialPageParam = 1, GetNextPageParam = info => info.Page.Count < PageSize ? PageParam.None : PageParam.Some(info.PageParam + 1), }); public void Dispose() => ItemsQuery.Dispose(); } ``` ```razor @inject ItemService Items @foreach (var item in state.Pages.SelectMany(p => p)) {
@item.Name
} @if (state.HasNextPage) { }

Loading…

@e.Message

@code { [Parameter] public Guid ListId { get; set; } protected override void OnParametersSet() => Items.ItemsQuery.SetArgs(ListId); } ``` --- ## IMutation ```csharp public interface IMutation : IDisposable { void SetEnabled(bool enabled); // when false, Execute() is a no-op IObservable> State { get; } IObservable Success { get; } IObservable Failure { get; } IObservable> Settled { get; } // Trigger the mutation. Cancels any previous in-flight execution (Switch semantics). void Execute(TArgs args); // Cancel the current execution; state returns to Idle; OnSettled fires but not OnSuccess/OnFailure void Cancel(); } ``` ### MutationOptions ```csharp new MutationOptions { // Required Mutator = (request, ct) => httpClient.PostAsJsonAsync("/api/users", request, ct), // Optional RetryHandler = null, // null = use global default IsEnabled = true, InvalidateKeys = [QueryKey.From("users")], // invalidated automatically on success // Lifecycle callbacks — execution order: OnMutate → mutator → InvalidateKeys → OnSuccess/OnFailure → OnSettled OnMutate = args => { /* snapshot + apply optimistic update */ }, OnSuccess = (args, result) => toast.Show($"Created {result.Name}"), OnFailure = error => toast.Show($"Error: {error.Message}"), OnSettled = () => isBusy = false, // fires for success, failure, AND cancellation } ``` `InvalidateKeys` fires immediately when the mutator's `Task` resolves. For eventually-consistent backends (read replicas, CQRS), use `OnSuccess` and call `queryClient.Invalidate(...)` manually to control timing. --- ## Caching The cache is owned by `IQueryClient` and keyed by `QueryKey`. All queries that produce the same key share one cache entry — only one fetch ever runs at a time for a given key (deduplication). **StaleTime** — how long fetched data is considered "fresh". Within this window `Invalidate()` is a no-op. - Default: `TimeSpan.Zero` (stale immediately after fetching) - `Refetch()` always bypasses stale time **CacheTime** — how long data is kept in memory after the last subscriber unsubscribes. - Default: 5 minutes - During this window a new subscriber gets the cached state immediately - After expiry the entry is evicted; the next subscriber starts fresh **RefetchInterval** — automatic polling while subscribers exist. Stops when the last subscriber unsubscribes. Invalidation via `IQueryClient`: ```csharp client.Invalidate(QueryKey.From("users", 42)); // exact key match client.Invalidate(key => key.ToString().StartsWith("users")); // predicate client.Invalidate(_ => true); // everything ``` --- ## IRetryHandler ```csharp public interface IRetryHandler { Task ExecuteAsync( Func> action, CancellationToken cancellationToken = default); } ``` The built-in `DefaultRetryHandler` makes a single attempt with no retry. Implement `IRetryHandler` to add custom policies. Rules for custom implementations: 1. Always rethrow `OperationCanceledException` immediately — never retry a cancellation. 2. Use `ExceptionDispatchInfo.Throw(last)` to rethrow to preserve the original stack trace. 3. Pass the `CancellationToken` to `Task.Delay` in backoff loops. Example — linear retry: ```csharp public sealed class LinearRetryHandler(int attempts, TimeSpan delay) : IRetryHandler { public async Task ExecuteAsync( Func> action, CancellationToken cancellationToken = default) { Exception? last = null; for (var i = 0; i < attempts; i++) { try { return await action(cancellationToken); } catch (OperationCanceledException) { throw; } catch (Exception ex) { last = ex; if (i < attempts - 1) await Task.Delay(delay, cancellationToken); } } ExceptionDispatchInfo.Throw(last!); throw null!; // unreachable } } ``` Polly integration: ```csharp public sealed class PollyRetryHandler(ResiliencePipeline pipeline) : IRetryHandler { public Task ExecuteAsync( Func> action, CancellationToken cancellationToken = default) => pipeline.ExecuteAsync(action, cancellationToken).AsTask(); } ``` --- ## Dependency Injection ```csharp // Program.cs builder.Services.AddFetchling(options => { options.StaleTime = TimeSpan.FromMinutes(1); options.CacheTime = TimeSpan.FromMinutes(10); options.RetryHandler = new MyRetryHandler(); options.ExecutionMode = QueryExecutionMode.Csr; // Singleton (default) // or options.ExecutionMode = QueryExecutionMode.Ssr; // Scoped (Blazor Server) }); ``` `IQueryClient` is registered based on `ExecutionMode`: - `Csr` (Client-Side Rendering / WebAssembly) → **Singleton** - `Ssr` (Server-Side Rendering / Blazor Server) → **Scoped** (per SignalR circuit) For Blazor Auto render mode, register both: ```csharp // Server project builder.Services.AddFetchling(o => o.ExecutionMode = QueryExecutionMode.Ssr); // Client project (WebAssembly) builder.Services.AddFetchling(o => o.ExecutionMode = QueryExecutionMode.Csr); ``` An optional `IScheduler` (from Rx.NET) can be registered in DI for testing — inject `TestScheduler` to control virtual time without real waits. --- ## Blazor Components Add to `_Imports.razor`: ```razor @using Fetchling.Blazor ``` ### `` Shows `Loading` while fetching (including background refetches), `Content` on success, `Failure` on error. ```razor

@user.Name

Loading...

Error: @error.Message

``` Parameters: `Query` (required), `Content` (required), `Loading` (optional), `Failure` (optional). Use when: you want a clean loading state between navigations, or the data must be fresh before rendering. ### `` Applies **stale-while-revalidate**: keeps showing the last successful data during background refetches. Only shows `Loading` when there is no previous data at all. ```razor @foreach (var item in items) { }

Loading...

@error.Message

``` Same parameters as ``. Use when: data updates frequently and you want smooth background refreshes without spinner flicker. | Scenario | Suspense | Transition | |---|---|---| | Initial load (no data) | Loading | Loading | | Background refetch (has old data) | Loading | old Content | | Success | Content | Content | | Failure (has old data) | Failure | old Content | | Failure (no old data) | Failure | Failure | ### `` For `IInfiniteQuery`. Shows `Loading` during initial load and full background refetches. Shows `Content` once data is available (`IsSuccess && HasData`). `Content` receives the full `InfiniteQueryState` so the template can render all pages and wire up "Load more". ```razor @foreach (var item in state.Pages.SelectMany(p => p)) {
@item.Name
} @if (state.HasNextPage) { }

Loading…

Error: @e.Message

``` ### `` For `IInfiniteQuery`. Stale-while-revalidate: shows `Content` whenever `HasData`, including during full background refetches and `FetchNextPage`. Shows `Loading` only before the very first page arrives. ```razor @foreach (var item in state.Pages.SelectMany(p => p)) {
@item.Name
} @if (state.HasNextPage) { }

Loading…

@e.Message

``` | Scenario | InfiniteSuspense | InfiniteTransition | |---|---|---| | Initial load (no pages) | Loading | Loading | | Full refetch with stale pages | Loading | old Content | | FetchNextPage in progress | Content + IsFetchingNextPage | Content + IsFetchingNextPage | | Success | Content | Content | | Failure (no pages) | Failure | Failure | | Failure (has pages) | Failure | old Content | Both components require three type parameters inferred from the `Query` parameter. ### `` Side-effect component. Registers browser `visibilitychange` and `online` events. When the tab regains focus or network reconnects, calls `queryClient.Invalidate(_ => true)`. Respects `StaleTime` — fresh data is never re-fetched. ```razor @* MainLayout.razor — place once near the root *@ @* reconnect only *@ @* focus only *@ ``` Requires an interactive render mode (WASM or interactive Blazor Server). No-op under static SSR. ### `` Live cache inspector panel (separate package `Fetchling.Blazor.DevTools`). Shows every cache entry with its key, status, observer count, and data. Supports filtering, invalidation, and drag-to-resize. ```razor @* MainLayout.razor *@ @using Fetchling.Blazor.DevTools @inject IHostEnvironment Env @if (Env.IsDevelopment()) { } ``` ### Cache Inspection (custom inspectors, upcoming 3.0) For non-Blazor UIs, `Fetchling.Core` exposes the cache that `` reads (public from the upcoming 3.0; internal in 2.0.0): ```csharp using Fetchling.Core.Inspection; if (client.TryGetInspector(out var inspector)) // false for custom IQueryClient implementations / mocks { inspector.Entries // IObservable> .ObserveOnUi() // Fetchling.Mvvm; emissions arrive on arbitrary threads .Subscribe(entries => { /* entry.Key, Name, Status, CurrentData, LastData, Error, LastUpdatedAt, ObserverCount, IsStale, CacheTime */ }); } ``` - `Entries` replays the current list on subscribe, then emits on add, eviction, cancelled eviction, and every entry state change. It completes when the client is disposed. Not coalesced. - Entries are live views (properties read current state), and the list is a membership snapshot. - Entries offer `Refetch()`, `Invalidate()`, and `Cancel()` but no `Dispose()`, because the cache owns them. - `CurrentData` is `null` while a regular query refetches; show `LastData` then. For infinite queries `CurrentData` is a `TData[]` of pages and `LastData` is always `null`. - `IsStale` is evaluated on read; going stale by elapsed time alone does not emit. - Subscribing to `Entries` does not count as an observer and does not delay eviction. --- ## MVVM View Models (Fetchling.Mvvm) `QueryViewModel` wraps an `IQuery` for XAML-based MVVM frameworks (MAUI, WPF, WinUI, UNO, Avalonia). It implements `INotifyPropertyChanged`, marshals all notifications onto the UI thread, and holds a live `State` subscription for its lifetime (which is what retains the cache entry and triggers deferred stale fetches). Zero dependencies beyond `Fetchling.Core` — no MVVM toolkit, no platform references. ```csharp public class QueryViewModel : BindableBase, IDisposable where TData : class { // Wraps an existing query; the CALLER keeps ownership (Dispose releases only the subscription) QueryViewModel(IQuery query, IUiDispatcher? dispatcher = null); // Creates a new observer via client.CreateQuery(options); the VIEW MODEL owns and disposes it. // Recommended for page view models with IQueryClient injected via DI. QueryViewModel(IQueryClient client, QueryOptions options, IUiDispatcher? dispatcher = null); IQuery Query { get; } // escape hatch: Invalidate(), PrefetchAsync(), Select(), Rx // Bindable properties — updated atomically; PropertyChanged raised only for actual changes QueryState CurrentState { get; } QueryStatus Status { get; } TData? Data { get; } // CurrentData; null while fetching TData? LastData { get; } TData? DisplayData { get; } // Data ?? LastData — stale-while-revalidate binding target Exception? Error { get; } bool IsIdle { get; } bool IsFetching { get; } // any fetch, incl. background refetch (subtle refresh indicator) bool IsSuccess { get; } bool IsFailure { get; } bool IsLoading { get; } // FIRST load only: fetching with no data to show (full-page spinner) bool HasData { get; } bool HasError { get; } ICommand RefetchCommand { get; } // Refetch(); CanExecute = !IsFetching ICommand CancelCommand { get; } // Cancel(); CanExecute = IsFetching void SetArgs(TArgs args); // passthroughs to the wrapped query void SetEnabled(bool enabled); void SetData(TData data); void Dispose(); // + protected virtual Dispose(bool) for derived view models } ``` ### UI-thread marshaling ```csharp public interface IUiDispatcher { void Post(Action action); } ``` Default: `SynchronizationContextUiDispatcher` captures `SynchronizationContext.Current` at construction — construct view models on the UI thread and MAUI/WPF/WinUI/UNO/Avalonia all work without extra wiring. Null context (unit tests, console) invokes inline. Rapid state bursts are coalesced latest-wins per UI hop; the whole state snapshot swaps before any `PropertyChanged` fires, so handlers never see torn sibling values. `CanExecuteChanged` is raised on the UI thread. Explicit platform adapters (one-liners): ```csharp public sealed class MainThreadUiDispatcher : IUiDispatcher // MAUI { public void Post(Action action) => MainThread.BeginInvokeOnMainThread(action); } public sealed class DispatcherQueueUiDispatcher(DispatcherQueue queue) : IUiDispatcher // WinUI/UNO { public void Post(Action action) => queue.TryEnqueue(() => action()); } public sealed class AvaloniaUiDispatcher : IUiDispatcher // Avalonia { public void Post(Action action) => Dispatcher.UIThread.Post(action); } ``` ### Lifecycle rules - **Dispose the view model when its page is torn down.** Disposing releases the state subscription (starting the CacheTime eviction clock) and disposes the query iff created via the `IQueryClient` ctor. A forgotten dispose pins the cache entry forever. - For temporarily hidden pages, use `SetEnabled(false)` on disappear / `SetEnabled(true)` on appear instead of disposing — invalidations defer while disabled and fetch on re-enable. - `BindableBase` (public INPC base with `RaisePropertyChanged` / `SetProperty`) is reusable for derived page view models. `QueryViewModel` is unsealed — derive and override `Dispose(bool)` for additional cleanup. ### Typical usage ```csharp public sealed class UserPageViewModel : IDisposable { public UserPageViewModel(IQueryClient queryClient, IUserApi api) => User = new QueryViewModel(queryClient, new QueryOptions { KeyFactory = id => QueryKey.From("users", id), Fetcher = (id, ct) => api.GetUserAsync(id, ct), StaleTime = TimeSpan.FromMinutes(5), }); public QueryViewModel User { get; } public void Load(int userId) => User.SetArgs(userId); public void Dispose() => User.Dispose(); } ``` ```xml
@code { [Parameter] public int Id { get; set; } protected override void OnParametersSet() => Users.UserQuery.SetArgs(Id); } ``` ### Optimistic Updates ```csharp UserDto? _snapshot = null; UpdateUser = queryClient.CreateMutation(new MutationOptions { Mutator = (user, ct) => api.UpdateUserAsync(user, ct), OnMutate = user => { _snapshot = UserQuery.CurrentState.CurrentData; // 1. snapshot UserQuery.Cancel(); // 2. stop in-flight fetch UserQuery.SetData(user); // 3. apply optimistic value }, OnFailure = _ => { if (_snapshot is not null) UserQuery.SetData(_snapshot); // 4. roll back on error }, // On success: replace optimistic data with the server-confirmed value InvalidateKeys = [QueryKey.From("users", ...)], }); ``` ### Prefetching Warm the cache before a component mounts (e.g. on hover): ```csharp private async Task OnItemHover(int userId) { // Skipped if data is still fresh (within StaleTime) await _userService.UserQuery.PrefetchAsync(userId); } ``` When the user clicks and the detail component mounts and calls `SetArgs(userId)`, data is already cached — no loading state. ### Conditional Queries ```csharp var query = queryClient.CreateQuery(new QueryOptions { KeyFactory = id => QueryKey.From("users", id), Fetcher = (id, ct) => api.GetUserAsync(id!.Value, ct), IsEnabled = false, // start disabled }); // Later, when user selects an ID: query.SetEnabled(true); query.SetArgs(selectedUserId); ``` ### Data Selector (derived observable) ```csharp // Subscribes to only the user's name, won't re-emit if name is unchanged IObservable userName = query.Select(user => user.Name); ``` --- ## Observability All telemetry uses BCL APIs only (`ActivitySource`, `Meter`, `ILogger`). No OpenTelemetry package is required in the library. ```csharp // Subscribe in your app project: builder.Services.AddOpenTelemetry() .WithTracing(b => b.AddSource(QueryTelemetry.SourceName)) // "Fetchling" .WithMetrics(b => b.AddMeter(QueryTelemetry.SourceName)); ``` Activity spans: | Span name | When | Tags | |---|---|---| | `query.fetch` | Every query fetch | `query.key`, `query.name`, `trigger`, `attempts`, `direction` + `query.pages` (infinite only), `otel.status_code`, `error.type` (on failure) | | `mutation.execute` | Every mutation execution | `mutation.name`, `attempts`, `otel.status_code`, `error.type` (on failure) | Metrics (meter name: `"Fetchling"`; duration histograms record seconds): | Instrument | Type | Unit | Tags | |---|---|---|---| | `fetchling.query.duration` | Histogram | s | `query.name`, `status`, `trigger` | | `fetchling.query.active` | UpDownCounter | — | `query.name` | | `fetchling.cache.hits` | Counter | — | `query.name` | | `fetchling.cache.misses` | Counter | — | `query.name` | | `fetchling.mutation.duration` | Histogram | s | `mutation.name`, `status` | Log category: `"Fetchling"`. Control verbosity: ```json { "Logging": { "LogLevel": { "Fetchling": "Warning" } } } ``` --- ## Caching Reference | Scenario | StaleTime | CacheTime | RefetchInterval | |---|---|---|---| | User profile (changes rarely) | 5–10 min | 30 min | — | | Live dashboard | 0 | 1 min | 10–30 s | | Config / feature flags | 1 h | 1 h | — | | Search results | 0 | 0 | — | | Notifications | 0 | 5 min | 30 s | --- ## Architecture Notes - `IQueryClient` is `QueryClient` internally, which owns a `QueryCache`. - `QueryCache` is a `ConcurrentDictionary` with timer-based eviction via `Observable.Timer`. - `CreateQuery` returns a `QueryObserver` — a key-switching proxy. Each `SetArgs` call creates or retrieves a `Query` from the cache and switches `_activeQuery` to it. - `Query` is the actual cache entry. It uses a `BehaviorSubject>` for state and `Observable.FromAsync(...).Switch()` so that a new invalidation cancels the previous in-flight fetch. - `Mutation` uses the same `.Switch()` pattern — a new `Execute()` cancels the previous execution. - When `Invalidate()` is called with no active subscribers, `_isStale = true` is set. The fetch is deferred and triggered by the first subscriber that joins. - `IScheduler` is injected everywhere time is used (stale-time, cache eviction, refetch intervals). Production uses `DefaultScheduler.Instance` (real clock). Tests inject `TestScheduler` from `Microsoft.Reactive.Testing` for deterministic time control. - `QueryClient` implements the public `IQueryCacheInspector` (namespace `Fetchling.Core.Inspection`, from the upcoming 3.0; internal in 2.0.0), reached via `client.TryGetInspector(out var inspector)`. See "Cache Inspection" above.