Announcing RabStack Query: TanStack Query for .NET
I’ve released RabStack Query, a port of TanStack Query to .NET with first-class support for Blazor and MAUI.
Most UI applications deal with two kinds of state: state that lives entirely in the client (which tab is selected, whether a modal is open) and server state: data owned by a remote server that your app holds a potentially-stale copy of. Managing server state is deceptively hard. You end up hand-rolling fetch logic, loading flags, cache invalidation, and retry handling in every component. TanStack Query solved this for the React ecosystem by letting you declare what data you need while a query client handles fetching, caching, deduplication, retries, and background refetching. RabStack Query brings that same model to .NET.
The Problem It Solves
Consider a component that loads bookmarks for a given category. The naive implementation is short and clean:
@inject HttpClient Http
@if (bookmarks is not null)
{
<ul>
@foreach (var bookmark in bookmarks)
{
<li>@bookmark.Title</li>
}
</ul>
}
@code {
[Parameter] public string Category { get; set; } = "";
private List<Bookmark>? bookmarks;
protected override async Task OnParametersSetAsync()
{
bookmarks = await Http.GetFromJsonAsync<List<Bookmark>>(
$"/api/bookmarks/{Category}");
}
} public partial class BookmarksViewModel : ObservableObject
{
private readonly HttpClient _httpClient;
[ObservableProperty]
public partial bool IsBusy { get; set; }
public ObservableCollection<Bookmark> Bookmarks { get; } = new();
public string Category { get; set; } = "all";
public BookmarksViewModel(HttpClient httpClient) => _httpClient = httpClient;
public async Task LoadAsync()
{
IsBusy = true;
try
{
var bookmarks = await _httpClient.GetFromJsonAsync<List<Bookmark>>(
$"/api/bookmarks/{Category}");
Bookmarks.Clear();
foreach (var bookmark in bookmarks)
Bookmarks.Add(bookmark);
}
finally
{
IsBusy = false;
}
}
} This code has at least five bugs hiding in it:
- Race condition. Switch categories quickly and two fetches overlap. If the first response arrives after the second, the UI shows stale data.
- Loading state breaks under composition. A single
IsBusyboolean works until a second code path (pull-to-refresh, retry button, tab switch) also needs to coordinate it. - Empty state is ambiguous. “No data” could mean “still loading,” “fetch returned nothing,” or “network error.” A
nullcheck can’t distinguish them. - Stale state across navigation. Navigate away from a failed fetch, come back, and you see the old error banner hovering over the wrong data.
- Lifecycle gotchas. Blazor’s
@rendermodemeans the component can execute on the server during prerendering and again on the client when interactivity starts. MAUI’sOnAppearingfires on tab switches, modal dismissals, and app resume.
By the time you fix all five - CancellationTokenSource management, error fields, state reset logic, IDisposable - a 15-line Blazor component grows to 40+ lines, and a 40-line MAUI ViewModel grows to 80+. And you still don’t have caching, background refetch, or deduplication.
The RabStack Query Version
@inherits RabstackComponentBase
@inject HttpClient Http
@if (_bookmarks.IsLoading)
{
<span>Loading...</span>
}
else if (_bookmarks.IsError)
{
<span>Failed to load bookmarks.</span>
}
else
{
<ul>
@foreach (var bookmark in _bookmarks.Data!)
{
<li>@bookmark.Title</li>
}
</ul>
}
@code {
[Parameter] public string Category { get; set; } = "";
private QueryViewModel<List<Bookmark>> _bookmarks = null!;
protected override void OnParametersSet()
{
_bookmarks?.Dispose();
_bookmarks = UseQuery(
queryKey: ["bookmarks", Category],
queryFn: ctx => Http.GetFromJsonAsync<List<Bookmark>>(
$"/api/bookmarks/{Category}", ctx.CancellationToken));
}
} public sealed class BookmarksViewModel : ObservableObject, IDisposable
{
public QueryViewModel<List<Bookmark>> Bookmarks { get; }
public BookmarksViewModel(
QueryClient client, HttpClient httpClient, string category)
{
Bookmarks = client.UseQuery(
queryKey: ["bookmarks", category],
queryFn: ctx => httpClient.GetFromJsonAsync<List<Bookmark>>(
$"/api/bookmarks/{category}", ctx.CancellationToken));
}
public void Dispose() => Bookmarks.Dispose();
}Dispose the ViewModel when the page is removed from the navigation stack.
And the XAML:
<ContentPage>
<Grid>
<ActivityIndicator IsRunning="{Binding Bookmarks.IsLoading}"
IsVisible="{Binding Bookmarks.IsLoading}" />
<Label Text="Failed to load bookmarks."
IsVisible="{Binding Bookmarks.IsError}" />
<CollectionView ItemsSource="{Binding Bookmarks.Data}"
IsVisible="{Binding Bookmarks.IsSuccess}">
<CollectionView.ItemTemplate>
<DataTemplate x:DataType="models:Bookmark">
<Label Text="{Binding Title}" />
</DataTemplate>
</CollectionView.ItemTemplate>
</CollectionView>
</Grid>
</ContentPage> All five bugs are handled. The query key ["bookmarks", category] is the identity of the request. When the category changes, the previous query is disposed (cancelling any in-flight request), and a fresh one is created. QueryViewModel exposes IsLoading, IsFetching, IsError, and IsSuccess as observable properties that update atomically - no booleans to forget to flip. Each query key gets its own isolated cache entry, so state never leaks between navigations. In the Blazor example, RabstackComponentBase handles the rest: it coalesces multiple property changes into a single render, and disposes all tracked queries when the component is removed.
And when the user navigates back, the data is already in the cache. The QueryViewModel delivers it immediately - no spinner - and if it’s stale, quietly refetches in the background. This is stale-while-revalidate at the application layer.
Features
Stale-while-revalidate caching. Cached data moves through three states: fresh (serve as-is), stale (serve immediately, refetch in the background), and evicted (must fetch from scratch). Configure StaleTime and GcTime globally or per-query.
Shared cache across components. Two components that query the same key share a single cache entry. Only one network request fires. Both update when the data arrives.
Mutations with optimistic updates. MutationViewModel provides MutateCommand as an IAsyncRelayCommand<TVariables> with OnMutate, OnSuccess, OnError, and OnSettled callbacks. Optimistic updates are first-class: update the cache optimistically, and roll back automatically on failure.
Infinite queries. InfiniteQueryViewModel handles cursor-based pagination with HasNextPage, FetchNextPageCommand, and automatic page accumulation.
Automatic retries. Failed queries retry with exponential backoff (1s, 2s, 4s, up to 30s). Configurable globally or per-query.
Automatic garbage collection. When no component observes a query, it stays in cache for GcTime (5 minutes by default), then cleans itself up.
Observability. Built-in System.Diagnostics.Metrics so you can monitor cache hit rates, fetch durations, and error rates.
Trim and AOT safe. No reflection. IsTrimmable and IsAotCompatible are set across the board.
Blazor component base class. RabstackComponentBase provides UseQuery, UseMutation, and UseInfiniteQuery methods that automatically subscribe to property changes, coalesce renders, and dispose queries when the component is removed.
Dehydrate and hydrate. Dehydrate snapshots the cache into a serializable form; Hydrate restores it as type-erased placeholders that upgrade to properly-typed queries on first use. Wire this into Blazor’s PersistentComponentState to transfer server-prerendered data to the client without a duplicate fetch.
DevTools. A built-in overlay for Blazor and MAUI that lets you inspect active queries and mutations, view cache state, and trigger error or loading states during development.
Streaming queries. StreamedQuery progressively updates cache entries from IAsyncEnumerable sources, with configurable refetch modes: reset, append, or buffer-and-replace.
Offline support. NetworkMode controls query and mutation behavior when connectivity drops. Paused mutations resume automatically on reconnect.
Polling. RefetchInterval keeps queries fresh with periodic background refetches, optionally continuing while the app is backgrounded.
Setup
Install the package for your framework:
dotnet add package RabstackQuery.BlazorRegister in Program.cs:
builder.Services.AddRabstackQuery(options =>
{
options.DefaultOptions = new QueryClientDefaultOptions
{
StaleTime = TimeSpan.FromSeconds(30),
};
}); dotnet add package RabstackQuery.MvvmRegister in MauiProgram.cs as a singleton so the cache survives navigation:
builder.Services.AddRabstackQuery(options =>
{
options.DefaultOptions = new QueryClientDefaultOptions
{
StaleTime = TimeSpan.FromSeconds(30),
};
}, ServiceLifetime.Singleton); Get Started
RabStack Query is open source under MIT on GitHub. Issues, feedback, and contributions are welcome.