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 })
Anonymous types are not supported

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:

DocsKeys.cs
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#

Hierarchical keys + prefix invalidation
Page 1

Current key: ["posts","list",{"page":1,"userId":null}]

Posts query IsLoading IsReFetching IsError IsCachedData IsStale ReFetchCount: 1UpdatedAt: 20:32:39
  • #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"));
}