Recipe: Paginated List¶
A paginated list is four states sharing one source of truth: an initial
load, a populated list, a "load the next page" affordance, and a terminal
"no more results." UseInfiniteResource owns
all four in Microsoft.UI.Reactor (Reactor) — the component below pattern-matches on LoadState and reads
Items directly; there is no local UseState for loading or errors.
Primitives¶
| Concern | API |
|---|---|
| Cursor-paginated fetch | UseInfiniteResource |
| Page payload + cursor | Page<TItem, TCursor> |
| Lifecycle discriminator | LoadState.Loading / Idle / EndOfList / Error |
| Flat sparse view | InfiniteResource.Items (nulls are unloaded slots) |
| Manual page advance | commits.FetchNext() / commits.Retry() |
Data + fetcher¶
// The recipe is API-shape-agnostic: the fetcher returns a Page<TItem, TCursor>
// regardless of whether the backend is REST, gRPC, or — as here — an in-process
// fake. The cursor is whatever the server hands back; null signals end-of-list.
record Commit(string Sha, string Message);
static class FakeApi
{
private static readonly Commit[] All = Enumerable.Range(0, 23)
.Select(i => new Commit($"sha-{i:000}", $"Refactor module {i}"))
.ToArray();
public static async Task<Page<Commit, string>> GetCommitsAsync(string? cursor, CancellationToken ct)
{
await Task.Delay(450, ct); // simulate network latency
const int pageSize = 5;
int offset = cursor is null ? 0 : int.Parse(cursor);
var slice = All.Skip(offset).Take(pageSize).ToArray();
int next = offset + slice.Length;
string? nextCursor = next >= All.Length ? null : next.ToString();
return new Page<Commit, string>(slice, nextCursor, TotalCount: All.Length);
}
}
The fetcher's only contract is (cursor, ct) -> Task<Page<TItem, TCursor>>.
The cursor is opaque to Reactor — pass back whatever your server speaks
(an offset, an opaque continuation string, a record id). A null
NextCursor is how you signal end-of-list; that's the only way LoadState
transitions to EndOfList.
One hook call¶
// UseInfiniteResource owns the fetch lifecycle: cancellation on deps-change,
// dedup of in-flight pages, a flat sparse `Items` list (null = unloaded slot),
// and a `LoadState` discriminator the UI pattern-matches against.
var commits = UseInfiniteResource<Commit, string>(
fetchPage: (cursor, ct) => FakeApi.GetCommitsAsync(cursor, ct),
deps: new object[] { "commits" });
UseInfiniteResource registers the fetcher, owns the cancellation token,
and survives re-renders. The deps array is the cache key — change it
(e.g. when a filter flips) and the hook cancels in-flight pages, drops
the page table, and refetches from page 0. See
UseState when you need a sibling piece of UI state
(filter, sort) — it pairs naturally because the setter triggers a
re-render and deps then drives the restart.
Derive UI state from the resource¶
// The hook exposes three observable signals the UI cares about:
// - LoadState — Loading / Idle / EndOfList / Error
// - Items — sparse flat list (null entries are in-flight or unloaded)
// - HasMore — false once the server reported a null NextCursor
// Everything below derives from these — no local UseState for "is loading"
// or "did it fail"; the hook is the source of truth.
var loadedItems = commits.Items.OfType<Commit>().ToArray();
var isInitialLoad = commits.LoadState is LoadState.Loading && loadedItems.Length == 0;
var error = commits.LoadState as LoadState.Error;
var atEnd = commits.LoadState is LoadState.EndOfList;
var loadingMore = commits.LoadState is LoadState.Loading && loadedItems.Length > 0;
There is no (loading, setLoading) and no (error, setError) in the
component. LoadState is the discriminator; Items.Count is the
loaded-anything-yet predicate. Deriving those locally on every render
is fine — the work is pure C# and the reconciler skips slots that
didn't change.
Render the four states¶
Element body;
if (isInitialLoad)
{
body = TextBlock("Loading…").Opacity(0.6).Padding(20);
}
else if (error is not null && loadedItems.Length == 0)
{
body = VStack(8,
TextBlock($"Couldn't load commits: {error.Exception.Message}")
.Foreground("#C42B1C"),
Button("Retry", () => commits.Retry())
).Padding(20);
}
else if (loadedItems.Length == 0)
{
body = TextBlock("No commits yet.").Opacity(0.6).Padding(20);
}
else
{
body = VStack(2,
loadedItems.Select(c =>
HStack(8,
TextBlock(c.Sha).Opacity(0.5).Width(72),
TextBlock(c.Message)
).Padding(6)
).ToArray()
);
}
// The footer is the load-more sentinel: a button while there's another page,
// a label once the server reported end-of-list, and a Retry on per-page error.
Element footer = atEnd
? TextBlock("— end of list —").Opacity(0.5).Padding(12)
: error is not null && loadedItems.Length > 0
? Button($"Retry — {error.Exception.Message}", () => commits.Retry()).Padding(8)
: Button(
loadingMore ? "Loading more…" : $"Load more ({commits.EstimatedRemaining} remaining)",
() => commits.FetchNext()
).IsEnabled(!loadingMore).Padding(8);
return VStack(0,
Heading($"Commits ({commits.TotalCount ?? loadedItems.Length})").Padding(20),
body,
footer
).Width(400);

The body branches on three predicates: initial load (skeleton), first-page
error with zero loaded items (full-screen retry), or a populated list.
The footer is the sentinel — a Button("Load more") while pages remain,
a "— end of list —" label once the server reported NextCursor == null,
or a per-page retry when a follow-up page errored after a successful first
load. Loaded items survive across re-renders because they live inside the
hook's Items view, not in local state.
Tips¶
Don't reach for UseState to remember pages. The hook's Items is
the page store. Mirroring it into a local list duplicates state — and the
local copy will go stale across the next deps-change refetch.
Cursor paging is serial; offset paging is parallel. Cursor mode
chains fetches because page N's cursor lives in page N-1. If your
server speaks offsets, use the cursorFromPageIndex parameter on
UseInfiniteResource so deep scrolls fetch
pages concurrently.
Promote to a virtualizer at row counts that matter. A button-driven
load-more is right for ~5-200 rows. Once the list grows past the
viewport's worth of pages, drive fetches from a virtualizer's
ItemAt(i) (see Collections) — the same hook
backs both flows.
Distinguish first-page error from follow-up error. A failure on the
initial fetch should take the whole list area; a failure loading page 3
should only replace the footer button. The recipe branches on
loadedItems.Length to choose between them.
Next Steps¶
- Async Resources — Full state-machine
reference for
UseInfiniteResource, includingRefresh()and the Pending fallback story. - Collections — Promote the recipe's
VStack-of-rows to a virtualizedListView<T>when the page count outpaces the viewport. - Data System — When the source already speaks
IDataSource<T>, useUseDataSourceto bridge onto this hook with offset-based parallel paging. - Recipe: Master-detail — Sibling recipe — pair
this list with a detail pane by lifting
selectedIdinto a parent component. - Recipes index — Back to the gallery.