Skip to content

Repository files navigation

gpui-query

gpui-query logo

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.

License: MIT

what it is

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.

install

[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.

quick start

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
    }
}

architecture

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"]

queries

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.

mutations

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.

infinite queries

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.

caching

Three policies:

  • NoCache fetches 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.

retry

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.

persistence

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.

other things worth knowing

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().

claude code skills

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-memory CachePolicy, RetryPolicy, QueryKey filters, QueryClient bulk ops, observers, GC.
  • gpui-query-extensions: the satellites. HTTP Cache-Control → CachePolicy + HttpCache (gpui-query-http), and durable disk persistence with FilePersister + the persist feature (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.md

Once 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.

links

license

MIT. See LICENSE.

Releases

Packages

Contributors

Languages