Async state management for GPUI, inspired by TanStack Query.
Fetch, cache, and synchronize async data in GPUI applications without hand-rolling the lifecycle. GPUI is the framework behind the Zed editor.
GPUI renders synchronously on the main thread. That makes async data awkward: you have to track loading states, handle errors, cache responses, deduplicate concurrent requests, and retry on failure. gpui-query handles all of it.
You write a fetcher function. The library manages caching, retry, deduplication, stale-while-revalidate, garbage collection, and cooperative cancellation on top of GPUI's Entity system.
The API mirrors TanStack Query: use_query, use_mutation, and use_infinite_query hooks that return Entity handles you read from in your view's render method.
[dependencies]
gpui-query = "0.3.0"This pulls in the client layer (which includes core). To use the declarative hooks:
[dependencies]
gpui-query = { version = "0.3.0", features = ["hook"] }To use only the core state machine with no GPUI dependency:
[dependencies]
gpui-query = { version = "0.3.0", default-features = false, features = ["core"] }The core layer also builds for wasm32-unknown-unknown; the wasm-specific setup (ahash switches to compile-time RNG on wasm targets) is handled internally. The client, hook, and persist layers are native-only: they depend on gpui, which does not build for wasm.
Set up the QueryClient as a GPUI global when your app starts:
use gpui::{Application, BorrowAppContext};
use gpui_query::QueryClient;
Application::new().run(|cx| {
cx.set_global(QueryClient::new());
});Fetch data with use_query:
use gpui::{Context, Entity, Subscription};
use gpui_query::{use_query, QueryResource};
struct MyView;
#[derive(Clone)]
struct User;
#[derive(Clone, Debug)]
struct MyError;
async fn fetch_users() -> Result<Vec<User>, MyError> {
Ok(vec![])
}
fn setup_query(
cx: &mut Context<MyView>,
) -> (Entity<QueryResource<Vec<User>, MyError>>, Subscription) {
use_query(
"users",
|_signal| async move {
let users = fetch_users().await?;
Ok::<Vec<User>, MyError>(users)
},
cx,
)
}Read the state in your render method:
use gpui::{Context, Entity};
use gpui_query::{QueryResource, QueryStatus};
#[derive(Clone)]
struct User;
#[derive(Clone, Debug)]
struct MyError;
struct MyView {
query_entity: Entity<QueryResource<Vec<User>, MyError>>,
}
impl MyView {
fn label(&self, cx: &Context<Self>) -> &'static str {
let label = self.query_entity.read_with(cx, |resource, _| match resource.status() {
QueryStatus::LoadingEmpty => "Loading...",
QueryStatus::Success => "Got data",
QueryStatus::Failure => "Error",
_ => "Idle",
});
label
}
}The library is four layers, each gated by a Cargo feature flag.
Core (feature = "core") is a serde-only state machine with zero framework coupling. This is where QueryResource<T,E>, MutationResource<V,T,E>, CachePolicy, RetryPolicy, QueryKey, and all the request lifecycle types live. You can use this layer in any Rust project.
Client (feature = "client", the default) builds on core and adds QueryClient, a GPUI Global that provides type-partitioned storage via QueryBucket<T,E>. This layer handles garbage collection, cache invalidation, observers, and devtools diagnostics.
Hook (feature = "hook") provides the declarative hooks (use_query, use_mutation, use_infinite_query) that wire the client layer into GPUI views. All hooks return (Entity, Subscription) tuples.
Persistence (feature = "persist") adds an async Persister trait, QueryClient::persist_with (debounced snapshot saves), the free hydrate() function for cold-start restore, and typed (de)serializer registries. See the Persistence guide.
[features]
default = ["client"]
core = []
client = ["core", "dep:gpui"]
hook = ["client"]
persist = ["client", "hook", "dep:serde_json", "dep:thiserror"]The primary hook. Pass a key (string or QueryOptions), a fetcher function, and the view context. The fetcher receives a QuerySignal for cooperative cancellation.
use gpui::Context;
use gpui_query::{use_query, CachePolicy, QueryOptions, QuerySignal, RetryPolicy};
#[derive(Clone)]
struct User;
#[derive(Clone, Debug)]
struct MyError;
fn example<C: 'static, F, Fut>(cx: &mut Context<C>, fetcher: F)
where
F: Fn(QuerySignal) -> Fut + Copy + Send + 'static,
Fut: std::future::Future<Output = Result<Vec<User>, MyError>> + Send + 'static,
{
let (entity, sub) = use_query("users", fetcher, cx);
let _ = (entity, sub);
let (entity, sub) = use_query(
QueryOptions::new("users")
.cache_policy(CachePolicy::Ttl { ttl_ms: 300_000 })
.retry_policy(RetryPolicy::new(5).with_exponential_backoff()),
fetcher,
cx,
);
let _ = (entity, sub);
}QueryResource<T,E> tracks the full lifecycle: idle, loading (with or without previous data), success, failure, or cancelled. You get data(), error(), status(), is_loading(), has_data(), cache_age_ms(now_ms), and retry_count().
For manual control with no auto-fetch, use use_query_manual and trigger fetches with fetch_query when you're ready.
use gpui::Context;
use gpui_query::{mutate, mutate_with_callbacks, use_mutation, MutationCallbacks};
#[derive(Clone)]
struct NewUser {
name: &'static str,
}
#[derive(Clone)]
struct User;
#[derive(Clone, Debug)]
struct MyError;
async fn create_user(vars: NewUser) -> User {
let _ = vars;
User
}
fn example<C: 'static>(cx: &mut Context<C>) {
let (entity, sub) = use_mutation((), cx);
let _ = sub;
mutate(&entity, NewUser { name: "Alice" }, |vars| async move {
Ok::<User, MyError>(create_user(vars).await)
}, cx);
let variables = NewUser { name: "Ada" };
let mutator = |vars: NewUser| async move { Ok::<User, MyError>(create_user(vars).await) };
mutate_with_callbacks(
&entity,
variables,
mutator,
MutationCallbacks::new()
.on_success(|_data| {})
.on_error(|err| eprintln!("mutation failed: {err:?}")),
cx,
);
}Mutations track their own state in MutationResource<V,T,E> with a begin/complete/retry/reset lifecycle. They don't touch the query cache unless you explicitly invalidate queries in an on_success callback.
For paginated data. The fetcher receives the last page (or None for the first request) and returns (page_data, has_more).
use gpui::Context;
use gpui_query::{use_infinite_query, InfiniteQueryOptions};
#[derive(Clone, Debug)]
struct MyError;
#[derive(Clone)]
struct Page {
cursor: u32,
}
impl Page {
fn cursor(&self) -> u32 {
self.cursor
}
}
struct FetchedPage {
items: Page,
has_more: bool,
}
async fn fetch_page(cursor: Option<u32>) -> Result<FetchedPage, MyError> {
let _ = cursor;
Ok(FetchedPage { items: Page { cursor: 0 }, has_more: false })
}
fn example<C: 'static>(cx: &mut Context<C>) {
let (entity, sub) = use_infinite_query(
InfiniteQueryOptions::new("feed").max_pages(10),
|last_page: Option<&Page>| {
let cursor = last_page.map(|p| p.cursor());
async move {
let page = fetch_page(cursor).await?;
Ok::<_, MyError>((page.items, page.has_more))
}
},
cx,
);
let _ = (entity, sub);
}Pages are stored in a VecDeque. Default cap is 50 pages, configurable via max_pages(). Supports bidirectional fetching with fetch_next_page_infinite and fetch_previous_page_infinite.
Three policies:
NoCachefetches every time.Ttl { ttl_ms }caches data for the specified duration. This is the default (60 seconds).StaleWhileRevalidate { ttl_ms, stale_ms }returns cached data immediately after TTL expires and kicks off a background refetch.
Bulk operations on the QueryClient:
use gpui::{App, BorrowAppContext};
use gpui_query::client::QueryClient;
use gpui_query::{QueryKey, QueryKeyFilter};
#[derive(Clone)]
struct User;
#[derive(Clone, Debug)]
struct MyError;
fn example(cx: &mut App, new_user: User) {
cx.update_global::<QueryClient, _>(|client, cx| {
client.invalidate_queries(&QueryKeyFilter::Prefix(&QueryKey::from(["users"])), cx);
client.remove_queries(&QueryKeyFilter::All);
// Optimistic update; the previous value is kept for the resource's
// rollback_to_previous()
client.set_query_data::<Vec<User>, MyError>("users", vec![new_user], cx);
});
}Invalidation matching supports Exact, Prefix, and All filters via QueryKeyFilter.
use gpui_query::RetryPolicy;
let policy = RetryPolicy::default();
let policy = RetryPolicy::new(5)
.with_delay(2_000)
.with_exponential_backoff()
.with_max_delay(60_000);Retry delay is base * 2^attempt, capped at max_delay. The fetcher's QuerySignal is checked between attempts, so cancelled queries stop retrying immediately.
Enable the persist feature to save and restore the cache across restarts. Implement the async Persister trait, then drive it with QueryClient::persist_with (debounced snapshot saves) and the free hydrate function (cold-start restore):
use std::time::Duration;
use gpui::App;
use gpui_query::client::{
hydrate, PersistError, PersistFilter, PersistOptions, Persister, PersistSnapshot, QueryClient,
};
struct MyPersister;
impl Persister for MyPersister {
async fn load(&self) -> Result<PersistSnapshot, PersistError> {
Ok(PersistSnapshot::new())
}
async fn save(&self, _snapshot: &PersistSnapshot) -> Result<(), PersistError> {
Ok(())
}
}
async fn example(mut client: &mut QueryClient, cx: &mut App) {
let _handle = client.persist_with(MyPersister, PersistOptions::default(), cx);
hydrate(
&mut client, &MyPersister, &PersistFilter::All, Duration::from_secs(86_400), cx,
)
.await
.ok();
}Only Success entries with a registered serializer are persisted; the typed round-trip is driven by QueryClient::register_serializer / register_deserializer. The companion crate gpui-query-persist ships a ready-made atomic disk adapter (FilePersister). See the Persistence guide.
QueryObserver and MutationObserver wrap entities and only call cx.notify() when the status changes, which keeps unrelated views from re-rendering.
QueryError::sanitized() redacts connection strings, bearer tokens, file paths, emails, and hex keys from error messages. Useful for logging without leaking secrets.
use_query_select projects a QueryResource<T,E> through a SelectTransform<T,U> to produce a MappedQueryResource that derives values from cached data.
ClientDiagnostic, QueryDiagnostic, and MutationDiagnostic give you runtime introspection of the query client's internal state for debugging.
PreparedFetch holds an entity, request ID, and signal for imperative one-shot fetches that you complete manually.
Garbage collection runs on idle resources older than gc_time_ms (default: 5 minutes). Configurable per-query via QueryOptions::gc_time_ms().
Two Claude Code skills ship in this repo under skills/. They teach an AI assistant the real gpui-query API (signatures, defaults, lifecycle, gotchas) so it writes correct hooks, caching, retry, and persistence code.
gpui-query: the essentials.use_query/use_mutation/use_infinite_query/use_query_select, in-memoryCachePolicy,RetryPolicy,QueryKeyfilters,QueryClientbulk ops, observers, GC.gpui-query-extensions: the satellites. HTTPCache-Control→CachePolicy+HttpCache(gpui-query-http), and durable disk persistence withFilePersister+ thepersistfeature (gpui-query-persist).
Install both globally (available in every project), from a clone of the repo:
cp -R skills/gpui-query skills/gpui-query-extensions ~/.claude/skills/Or grab a single skill without cloning:
mkdir -p ~/.claude/skills/gpui-query
curl -fsSL https://raw.githubusercontent.com/freeoxide/gpui-query/master/skills/gpui-query/SKILL.md \
-o ~/.claude/skills/gpui-query/SKILL.mdOnce installed, the skills activate automatically whenever you work on a GPUI app that depends on gpui-query. See the Claude Code skills guide for details.
- Documentation: https://gpui-query.freeoxide.com/docs/
- Website: https://gpui-query.freeoxide.com
- Source: https://github.com/freeoxide/gpui-query
- GPUI framework: https://github.com/zed-industries/zed/tree/main/crates/gpui
- TanStack Query: https://tanstack.com/query
MIT. See LICENSE.
