Query keys
A QueryKey is a structured, ordered cache identity. Build hierarchical keys and
you get prefix invalidation for free.
Building keys#
QueryKey.Of("todos") // ["todos"]
QueryKey.Of("todos", "detail", 5) // ["todos","detail",5]
QueryKey.Of("posts", new Dictionary<string, object?> { ["userId"] = 1, ["page"] = 2 })
Segment order matters — Of("todos", 1) and Of(1, "todos") are
different keys. Supported segment types: strings, numeric primitives, bool,
char, Guid, dates and times, TimeSpan, enums,
null, collections of these, and string-keyed dictionaries. An unsupported type
throws at construction — failing loudly at the call site beats a silently colliding entry.
Canonical form#
Each key computes a canonical string once (what ToString() shows, e.g.
["todos",{"page":2}]). Strings are quoted and escaped so ["a,b"] and
["a","b"] can't collide; numbers and dates use the invariant culture; enums use
their numeric value so renaming a member can't silently change a key. Equality — including
== — is value equality on this canonical form.
Dictionaries normalise filter objects#
Dictionary segments are written with their keys sorted, so property order in an ad-hoc filter doesn't change identity:
// Same key, either way — dictionary keys are ordinal-sorted in the canonical form.
QueryKey.Of("posts", new Dictionary<string, object?> { ["page"] = 2, ["status"] = "open" })
QueryKey.Of("posts", new Dictionary<string, object?> { ["status"] = "open", ["page"] = 2 })QueryKey.Of("todos", new { Page = 2 }) throws. Key hashing runs on every cache
lookup and must stay trimming- and AOT-safe, which reflection over anonymous types is not.
Use a Dictionary<string, object?> for ad-hoc shapes, or implement
IQueryKeySegment on a named type.
Prefix matching#
StartsWith compares segment-by-segment on canonical forms — never as a string
prefix, so ["todo"] does not match ["todos"]. Invalidation,
Remove and RefetchAsync all match by prefix by default:
await Client.InvalidateAsync(QueryKey.Of("todos"));
// hits ["todos"], ["todos","list",8], ["todos","detail",5], …
// but NOT ["todo"] or ["todoLists"]IQueryKeySegment#
Implement IQueryKeySegment on a custom type to let it participate directly:
ToKeySegment() must return a stable, deterministic string — equal values, equal
strings, across sessions.
Strings convert implicitly#
QueryKey = "todos" means QueryKey.Of("todos"). Null or whitespace
converts to no key — the uncached path.
Model: a keys class#
This site keeps every demo key in one static class — one place to see the whole hierarchy:
public static class DocsKeys
{
public static QueryKey Todos => QueryKey.Of("todos");
public static QueryKey TodoList(int limit) => QueryKey.Of("todos", "list", limit);
public static QueryKey TodoDetail(int id) => QueryKey.Of("todos", "detail", id);
public static QueryKey Posts(int? userId, int page) => QueryKey.Of(
"posts", "list", new Dictionary<string, object?> { ["userId"] = userId, ["page"] = page });
}Live demo#
Current key: ["posts","list",{"page":1,"userId":null}]
- #1 · u1 — sunt aut facere repellat provident occaecati excepturi optio reprehenderit
- #2 · u1 — qui est esse
- #3 · u1 — ea molestias quasi exercitationem repellat qui ipsa sit aut
- #4 · u1 — eum et est occaecati
- #5 · u1 — nesciunt quas odio
Each author/page combination is its own cache entry — flip back to a visited page and it
renders instantly. The dictionary segment normalises property order, and invalidating the
["posts"] prefix marks every combination stale in one call.
@inherits DejaComponentBase
@inject JsonPlaceholderApi Api
@inject DejaClient Client
<div class="demo-toolbar">
<label>
Author:
<select class="demo-input" @onchange="OnUserChanged">
<option value="" selected="@(_userId is null)">All authors</option>
<option value="1" selected="@(_userId == 1)">User 1</option>
<option value="2" selected="@(_userId == 2)">User 2</option>
</select>
</label>
<button class="demo-button" @onclick="() => ChangePage(-1)" disabled="@(_page <= 1)">←</button>
<span>Page @_page</span>
<button class="demo-button" @onclick="() => ChangePage(1)">→</button>
<button class="demo-button" @onclick="InvalidatePrefix">Invalidate ["posts"] prefix</button>
</div>
<p class="demo-note">Current key: <code>@DocsKeys.Posts(_userId, _page).ToString()</code></p>
<StateInspector Query="_posts" Label="Posts query"/>
@if (_posts.Data is { } posts)
{
<ul class="demo-list">
@foreach (var post in posts.Take(5))
{
<li>#@post.Id · u@(post.UserId) — @post.Title</li>
}
</ul>
}
<p class="demo-note">
Each author/page combination is its own cache entry — flip back to a visited page and it
renders instantly. The dictionary segment normalises property order, and invalidating the
<code>["posts"]</code> prefix marks every combination stale in one call.
</p>
@code {
private readonly Query<IReadOnlyList<PostDto>> _posts = new();
private int? _userId;
private int _page = 1;
protected override Task OnInitializedAsync() => Load();
private Task Load() => _posts.Execute(
DocsKeys.Posts(_userId, _page),
token => Api.GetPostsAsync(_userId, _page, token),
p => p.OnError = _ => { });
private Task OnUserChanged(ChangeEventArgs e)
{
_userId = int.TryParse((string?)e.Value, out var id) ? id : null;
_page = 1;
return Load();
}
private Task ChangePage(int delta)
{
_page = Math.Max(1, _page + delta);
return Load();
}
private Task InvalidatePrefix() => Client.InvalidateAsync(QueryKey.Of("posts"));
}