Query Keys
Every cached resource in gpui-query is identified by a QueryKey — a structured, hierarchical key inspired by TanStack Query's array keys (["users", "42", "posts"]). Keys are the foundation of caching, deduplication, and the bulk operations (invalidate_queries, cancel_queries, reset_queries, remove_queries) that the QueryClient exposes.
Creating a key
A QueryKey is an ordered sequence of string segments. The simplest case is a single string:
use gpui_query::QueryKey;
let key = QueryKey::from("users");
For resources scoped by an identifier, build a multi-segment key. The key records the order of the segments, so ["users", "42"] and ["42", "users"] are different keys.
let user_key = QueryKey::from(["users", "42"]);
let user_posts = QueryKey::from(["users", "42", "posts"]);
let post_detail = QueryKey::from(["posts", "7"]);
You can construct a key from any of:
&str/String(single segment)[&str; N],Vec<&str>,Vec<String>(multiple segments)QueryKey::new(iterator)(panics on an empty iterator — a key must have at least one segment)
Cheap cloning
Internally a QueryKey stores its segments in an Arc<[Arc<str>]>, so cloning a key is a single atomic ref-count increment regardless of how many segments it holds. This matters because the hook layer and the QueryClient both clone keys freely (into buckets, into fetch tasks, into diagnostics). You never pay an allocation per clone.
let key = QueryKey::from(["users", "42", "posts", "comments"]);
let cloned = key.clone();
assert!(std::sync::Arc::ptr_eq(&key.parts()[0], &cloned.parts()[0]));
Prefix matching
Two methods make hierarchical keys useful:
starts_with(prefix)—truewhen this key begins withprefix. This is the primitive the bulk operations use to invalidate a whole subtree.to_path()— ausers::42::posts-style display string for DevTools and logs. The::separator avoids ambiguity when a segment itself contains a forward slash.
use gpui_query::QueryKey;
let posts = QueryKey::from(["users", "42", "posts"]);
assert!(posts.starts_with(&QueryKey::from(["users"])));
assert!(posts.starts_with(&QueryKey::from(["users", "42"])));
assert!(!posts.starts_with(&QueryKey::from(["posts"])));
assert_eq!(posts.to_path(), "users::42::posts");
Adopt a convention where each segment narrows the scope: ["resource", "id", "view"]. Then ["users"] is the whole users subtree, ["users", "42"] is everything for user 42, and ["users", "42", "posts"] is that user's posts. Bulk invalidation falls out of this naturally.
Using keys with hooks
The options-first hooks accept anything that converts Into<QueryKey>, so you can pass a bare string for the simple case or a structured key for a scoped resource:
use gpui_query::hook::use_query;
use gpui_query::{QueryOptions, QueryKey};
# #[derive(Clone)]
# struct User;
# #[derive(Clone, Debug)]
# struct MyError;
impl View {
fn new(cx: &mut gpui::Context<Self>) -> Self {
// Single string — the common case.
let (users, _) = use_query("users", |_signal| async move {
Ok::<_, MyError>(vec![])
}, cx);
// Structured key for a scoped resource.
let (user, _) = use_query(
QueryKey::from(["users", "42"]),
|_signal| async move { Ok::<_, MyError>(User) },
cx,
);
// Equivalent, via QueryOptions:
let _ = use_query(
QueryOptions::new(["users", "42", "posts"]),
|_signal| async move { Ok::<_, MyError>(vec![]) },
cx,
);
// ...
# unimplemented!()
}
}
QueryKeyFilter
The bulk operations on QueryClient do not take a raw key — they take a QueryKeyFilter, which is how you say which keys to act on. There are three variants:
| Filter | Matches |
|---|---|
Exact(k) | Only the key that is exactly equal to k. |
Prefix(k) | Every key that starts_with(k) — the whole subtree. |
All | Every key in the cache. |
QueryKeyFilter is Copy, so you can build it inline at the call site:
use gpui_query::client::QueryClient;
use gpui_query::core::{QueryKey, QueryKeyFilter};
# fn _doc(client: &mut QueryClient, cx: &mut gpui::App) {
// Invalidate everything under the "users" subtree — every user,
// every user's posts, etc.
let users = QueryKey::from(["users"]);
client.invalidate_queries(&QueryKeyFilter::Prefix(&users), cx);
// Invalidate just one specific resource.
let one = QueryKey::from(["users", "42"]);
client.invalidate_queries(&QueryKeyFilter::Exact(&one), cx);
// Throw away all cached query state (e.g. on logout).
client.remove_queries(&QueryKeyFilter::All);
# }
Choosing a filter
Exactwhen you know the full key — for example, after a mutation that updates a single record.invalidate_queries(Exact(&["users", "42"]))refetches only that user.Prefixwhen a mutation affects a family of resources — for example, after creating a user,invalidate_queries(Prefix(&["users"]))refetches the users list and every cached user.Allfor global resets, most commonly on logout:remove_queries(All)clears the cache, andreset_queries(All)clears both data and cancels in-flight requests.
Patterns
Scoped keys for list + detail
Keep the list and a detail view under a shared prefix so one invalidation refreshes both:
let list = QueryKey::from(["users"]); // the list
let detail= QueryKey::from(["users", id.as_str()]); // one user
After an update mutation, invalidate_queries(Prefix(&["users"])) refreshes the list and any cached detail pages in one call.
Dynamic segments
QueryKey only stores strings. To scope by a non-string id, format it into the key:
let key = QueryKey::from(["projects", &project_id.to_string(), "members"]);
Avoid empty keys
QueryKey::new with a zero-length iterator panics. Every key must have at least one segment — there is no "root" key. Use QueryKeyFilter::All when you mean "everything".
Next steps
QueryClient— the bulk operations that consume aQueryKeyFilter.- Caching — how
CachePolicyinteracts with keys. - Error handling — surfacing failures keyed by resource.