# UseQuery
*Fetch, cache, and synchronize server data with the [UseQuery](../01_RulesOfHooks.md) hook.*
The `UseQuery` [hook](../01_RulesOfHooks.md) provides a powerful way to fetch and cache asynchronous data. Inspired by [SWR](https://swr.vercel.app/) (stale-while-revalidate), it returns cached data immediately while revalidating in the background, keeping your [views](../../../01_Onboarding/02_Concepts/02_Views.md) fast and your data fresh.
## Basic Usage
The simplest form of `UseQuery` takes a key and a fetcher function:
```csharp
public class BasicQueryView : ViewBase
{
public override object? Build()
{
var query = UseQuery(
key: "user-profile",
fetcher: async ct =>
{
await Task.Delay(1000, ct);
return new { Name = "Alice", Email = "alice@example.com" };
});
if (query.Loading) return "Loading...";
return Layout.Vertical()
| query //query has a Build extension that produces a debug view
| query.Value?.Name
| query.Value?.Email
| (Layout.Horizontal()
| new Button("Revalidate", _ => query.Mutator.Revalidate()).Variant(ButtonVariant.Primary)
| new Button("Invalidate", _ => query.Mutator.Invalidate()).Variant(ButtonVariant.Primary))
;
}
}
```
## Query Result
`UseQuery` returns a `QueryResult<T>` with the following properties:
| Property | Type | Description |
|----------|------|-------------|
| `Value` | `T?` | The fetched data |
| `Loading` | `bool` | True during initial fetch |
| `Validating` | `bool` | True during background revalidation |
| `Previous` | `bool` | True when showing stale data during key change |
| `Error` | `Exception?` | The error if fetch failed |
| `Mutator` | `QueryMutator<T>` | Methods to mutate the cache |
## Query Options
Configure query behavior with `QueryOptions`:
```csharp
var query = UseQuery(
key: "data",
fetcher: FetchData,
options: new QueryOptions
{
Scope = QueryScope.Server, // Cache scope
Expiration = TimeSpan.FromMinutes(5), // TTL before revalidation
KeepPrevious = true, // Keep previous data during key change
RefreshInterval = TimeSpan.FromSeconds(30), // Auto-refresh interval
RevalidateOnMount = true // Fetch on mount
});
```
## Query Scopes
Control where query data is cached and shared:
| Scope | Description |
|-------|-------------------------------------------------------|
| `Server` | Shared across all users (default) |
| `App` | Shared within a app session |
| `Device` | Shared across apps on same device |
| `View` | Isolated to component instance, cleaned up on unmount |
## Conditional Fetching
When the key is `null` (often controlled by [UseState](./03_UseState.md)), UseQuery returns an idle result without fetching:
```csharp
public class ConditionalQueryView : ViewBase
{
public override object? Build()
{
var shouldFetch = UseState(false);
var query = UseQuery(
key: shouldFetch.Value ? "data" : null,
fetcher: async ct =>
{
await Task.Delay(1000, ct);
return $"Fetched at {DateTime.Now:HH:mm:ss}";
});
return Layout.Vertical()
| shouldFetch.ToBoolInput().Label("Enable fetching")
| (shouldFetch.Value
? query.Loading
? Text.Literal("Loading...")
: Text.Literal(query.Value ?? "")
: Text.Muted("Fetching disabled"));
}
}
```
## Dependent Fetching
Use a key factory to fetch data that depends on another query:
```csharp
public class DependentQueryView : ViewBase
{
public override object? Build()
{
var user = UseQuery(
key: "user",
fetcher: async ct =>
{
await Task.Delay(800, ct);
return new { Id = 42, Name = "Alice" };
});
// Only fetches when user is loaded
var projects = UseQuery(
() => user.Value?.Id,
async (userId, ct) =>
{
await Task.Delay(800, ct);
return new[] { $"Project A (user {userId})", $"Project B (user {userId})" };
});
return Layout.Vertical()
| Text.Literal($"User: {(user.Loading ? "Loading..." : user.Value?.Name)}")
| Text.Literal($"Projects: {(projects.Loading ? "Loading..." : string.Join(", ", projects.Value ?? []))}");
}
}
```
## Tag-Based Invalidation
Assign tags to queries for bulk invalidation (using [UseService](./11_UseService.md)). Tags are serializable the same way as keys.
```csharp
public class TaggedQueriesView : ViewBase
{
public override object? Build()
{
var queryService = UseService<IQueryService>();
var users = UseQuery(
key: "dashboard/users",
fetcher: async ct =>
{
await Task.Delay(500, ct);
return $"Users: {Random.Shared.Next(100, 500)}";
},
tags: ["dashboard", "users"]);
var orders = UseQuery(
key: "dashboard/orders",
fetcher: async ct =>
{
await Task.Delay(500, ct);
return $"Orders: {Random.Shared.Next(50, 200)}";
},
tags: ["dashboard", "orders"]);
return Layout.Vertical()
| Text.Literal(users.Loading ? "Loading..." : users.Value ?? "")
| Text.Literal(orders.Loading ? "Loading..." : orders.Value ?? "")
| (Layout.Horizontal()
| new Button("Refresh All", _ => queryService.RevalidateByTag("dashboard"))
| new Button("Invalidate All", _ => queryService.InvalidateByTag("dashboard"))
);
}
}
```
## Mutations
The `Mutator` provides methods to update cached data:
| Method | Description |
|--------|-------------|
| `Mutate(value, revalidate)` | Update cache with new value |
| `Revalidate()` | Trigger background revalidation |
| `Invalidate()` | Clear cache and refetch |
```csharp
public class MutationView : ViewBase
{
public override object? Build()
{
var query = UseQuery(
key: "counter",
fetcher: async ct =>
{
await Task.Delay(500, ct);
return Random.Shared.Next(1, 100);
});
if (query.Loading)
return Text.Literal("Loading...");
return Layout.Vertical()
| (Layout.Horizontal()
| new Button("+10 (Optimistic)", _ =>
query.Mutator.Mutate(query.Value + 10, revalidate: true))
.Variant(ButtonVariant.Primary)
| new Button("Set 999", _ =>
query.Mutator.Mutate(999, revalidate: false))
.Variant(ButtonVariant.Secondary)
| new Button("Refresh", _ => query.Mutator.Revalidate())
.Variant(ButtonVariant.Outline))
| Text.Literal($"Value: {query.Value}")
| (query.Validating ? Text.Muted("Syncing...") : null!);
}
}
```
### Cross-Component Mutations
Use `UseMutation` to control a query by key (e.g. from another component):
```csharp
public class UseMutationView : ViewBase
{
public override object? Build()
{
var query = UseQuery(
key: "shared-data",
fetcher: async ct =>
{
await Task.Delay(500, ct);
return $"Data: {Guid.NewGuid().ToString()[..8]}";
});
var mutator = UseMutation("shared-data");
return Layout.Vertical()
| (Layout.Horizontal()
| Text.Literal(query.Loading ? "Loading..." : query.Value ?? "")
| (query.Validating ? Text.Muted(" (updating...)") : null!))
| (Layout.Horizontal()
| new Button("Revalidate", _ => mutator.Revalidate())
.Variant(ButtonVariant.Outline)
| new Button("Invalidate", _ => mutator.Invalidate())
.Variant(ButtonVariant.Destructive));
}
}
```
## Examples
### Polling
Use `RefreshInterval` to revalidate at intervals, or trigger revalidation manually on a timer. This example uses manual `Revalidate()` every 5 seconds for 30 seconds after you click the button, so the UI updates and no permanent polling runs.
```csharp
public class PollingView : ViewBase
{
public override object? Build()
{
var pollingEnabled = UseState(false);
var liveData = UseQuery(
key: "live-data",
fetcher: async ct =>
{
await Task.Delay(300, ct);
return new
{
Value = Random.Shared.Next(100, 999),
Timestamp = DateTime.Now
};
});
UseEffect(() =>
{
if (!pollingEnabled.Value) return new CancellationTokenSource();
var cts = new CancellationTokenSource();
var endAt = DateTime.UtcNow.AddSeconds(30);
_ = Task.Run(async () =>
{
while (!cts.Token.IsCancellationRequested && DateTime.UtcNow < endAt)
{
liveData.Mutator.Revalidate();
await Task.Delay(TimeSpan.FromSeconds(5), cts.Token);
}
if (!cts.Token.IsCancellationRequested)
pollingEnabled.Set(false);
});
return cts;
}, pollingEnabled);
return Layout.Vertical()
| Text.Literal($"Value: {liveData.Value?.Value}")
| Text.Muted($"Updated: {liveData.Value?.Timestamp:HH:mm:ss}")
| (liveData.Validating ? Text.Muted("Refreshing...") : null!)
| new Button(
pollingEnabled.Value ? "Stop polling" : "Start polling (5s × 30s)",
_ => pollingEnabled.Set(!pollingEnabled.Value));
}
}
```
### Pagination
Use `KeepPrevious` to show previous page data while loading the next. Combine [UseQuery](./09_UseQuery.md) with the [Pagination](../../02_Widgets/03_Common/09_Pagination.md) widget:
```csharp
public class PaginatedView : ViewBase
{
private const int PageSize = 5;
private const int TotalPages = 10;
public override object? Build()
{
var page = UseState(1);
var items = UseQuery(
key: $"items?page={page.Value}",
fetcher: async ct =>
{
await Task.Delay(800, ct);
var start = (page.Value - 1) * PageSize;
return Enumerable.Range(start + 1, PageSize)
.Select(i => i.ToString())
.ToList();
},
options: new QueryOptions { KeepPrevious = true });
return Layout.Vertical()
| (items.Previous ? Text.Muted("Loading next page...") : null!)
| Layout.Horizontal(items.Value?.Select(Text.Literal) ?? [])
| new Pagination(page.Value, TotalPages, p => page.Set(p.Value));
}
}
```
### Pre-Populated Data
Skip initial fetch when you already have data (e.g., from a list view):
```csharp
public class ProductListView : ViewBase
{
public override object? Build()
{
var products = UseQuery(
key: "products",
fetcher: async ct =>
{
await Task.Delay(1000, ct);
return new[]
{
new Product(1, "Widget", 9.99m),
new Product(2, "Gadget", 19.99m)
};
});
if (products.Loading)
return Text.Literal("Loading...");
return Layout.Vertical(
products.Value?.Select(p => new ProductDetailView(p))
);
}
}
public record Product(int Id, string Name, decimal Price);
public class ProductDetailView(Product initialProduct) : ViewBase
{
public override object? Build()
{
// Use list data immediately, skip initial fetch
var product = UseQuery(
key: $"product/{initialProduct.Id}",
fetcher: ct => FetchProduct(initialProduct.Id, ct),
options: new QueryOptions { RevalidateOnMount = false },
initialValue: initialProduct);
return new Card(
Layout.Vertical()
| Text.Literal(product.Value?.Name ?? "")
| Text.Literal($"${product.Value?.Price}")
).Small();
}
private async Task<Product> FetchProduct(int id, CancellationToken ct)
{
await Task.Delay(500, ct);
return new Product(id, "Updated Name", 29.99m);
}
}
```
### Error Handling
Errors are captured in the `Error` property:
```csharp
public class ErrorHandlingView : ViewBase
{
public override object? Build()
{
var query = UseQuery(
key: "risky-data",
fetcher: async ct =>
{
await Task.Delay(1000, ct);
if (Random.Shared.NextDouble() > 0.5)
throw new Exception("Network error");
return "Success!";
});
if (query.Loading)
return Text.Literal("Loading...");
if (query.Error is { } error)
{
return Layout.Vertical()
| Callout.Error(error.Message)
| new Button("Retry", _ => query.Mutator.Revalidate())
.Variant(ButtonVariant.Outline);
}
return Layout.Vertical()
| Text.Literal(query.Value ?? "")
| new Button("Refresh", _ => query.Mutator.Revalidate())
.Variant(ButtonVariant.Primary);
}
}
```