Infinite Queries
Infinite queries handle paginated data: feed streams, cursor-based lists, any scenario where you load a chunk at a time and might need more later. The API is modeled after TanStack Query's useInfiniteQuery, adapted to GPUI's synchronous rendering model and Rust's ownership semantics.
use_infinite_query
use gpui_query::{use_infinite_query, InfiniteQueryOptions, QueryKey};
use gpui::{Entity, Subscription};
let (entity, subscription) = use_infinite_query(
InfiniteQueryOptions::new(QueryKey::from(["feed"])),
|last_page| async move {
let cursor = last_page.map(|p| p.cursor());
let page = fetch_page(cursor).await?;
Ok::<_, MyError>((page.items, page.has_more))
},
cx,
);
The hook returns two things: an Entity<InfiniteQueryResource<T, E>> holding all loaded pages, and a Subscription that keeps the observation alive. Drop the subscription and the view stops re-rendering on state changes.
The fetcher closure receives Option<&T> (the last page for forward fetches, the first page for backward fetches, or None on the initial request). It returns Result<(T, bool), E> where the bool tells the query whether more pages exist.
On creation, the hook starts an initial fetch if the resource is idle. The entity is registered with QueryClient for shared caching and garbage collection.
Minimal example
struct FeedView {
feed: Entity<InfiniteQueryResource<Vec<Post>, MyError>>,
_subscription: Subscription,
}
impl FeedView {
fn new(cx: &mut Context<Self>) -> Self {
let (entity, sub) = use_infinite_query(
InfiniteQueryOptions::new(QueryKey::from(["feed"])),
|last_page| async move {
let cursor = last_page.and_then(|p| p.last().map(|post| post.id));
let resp = api::fetch_feed(cursor).await?;
Ok((resp.items, resp.has_next))
},
cx,
);
Self { feed: entity, _subscription: sub }
}
}
fetch_next_page_infinite
Call this from event handlers to load the next page. Common trigger: the user scrolled to the bottom of a list.
use gpui_query::fetch_next_page_infinite;
fn on_scroll_to_bottom(&mut self, cx: &mut Context<Self>) {
fetch_next_page_infinite(
&self.feed,
|last_page| async move {
let cursor = last_page.and_then(|p| p.last().map(|post| post.id));
let resp = api::fetch_feed(cursor).await?;
Ok((resp.items, resp.has_next))
},
cx,
);
}
The function reads the last page from the entity and passes it to your fetcher. It returns nothing; the entity updates in place and the view re-renders when the fetch completes.
If has_next_page() is false, the call does nothing. If a fetch is already in flight under RequestPolicy::LatestWins, the old request is cancelled and replaced.
fetch_previous_page_infinite
Same shape as fetch_next_page_infinite, but fetches in the backward direction. The fetcher receives the first page (not the last) so you can compute a cursor for older content.
use gpui_query::fetch_previous_page_infinite;
fn on_scroll_to_top(&mut self, cx: &mut Context<Self>) {
fetch_previous_page_infinite(
&self.feed,
|first_page| async move {
let cursor = first_page.and_then(|p| p.first().map(|post| post.id));
let resp = api::fetch_feed_before(cursor).await?;
Ok((resp.items, resp.has_prev))
},
cx,
);
}
This is useful for chat interfaces or timelines that grow in both directions. Set FetchDirection::Bidirectional on the resource so has_previous_page defaults correctly.
InfiniteQueryResource<T, E>
The core state container. It holds pages in a VecDeque<T> and tracks fetch status, error state, and direction flags.
Construction
// Forward-only (default). has_next_page starts true.
let mut resource = InfiniteQueryResource::new(
QueryKey::from("items"),
CachePolicy::Ttl { ttl_ms: 60_000 },
RequestPolicy::LatestWins,
);
// Bidirectional. Both has_next_page and has_previous_page start false.
let mut resource = InfiniteQueryResource::new_bidirectional(
QueryKey::from("items"),
CachePolicy::Ttl { ttl_ms: 60_000 },
RequestPolicy::LatestWins,
);
Use new_bidirectional when you need to prepend pages. The default constructor assumes forward-only pagination.
Page accessors
| Method | Returns | Notes |
|---|---|---|
pages() | &VecDeque | All loaded pages, first to last |
page_count() | usize | Number of loaded pages |
first_page() | Option<&T> | First loaded page |
last_page() | Option<&T> | Last loaded page |
has_next_page() | bool | Whether more pages exist after the last |
has_previous_page() | bool | Whether more pages exist before the first |
has_data() | bool | True when at least one page is loaded |
is_page_data_valid() | bool | True when loaded pages are safe to use |
is_page_data_valid() deserves a note. It returns true on Success, on LoadingWithData (previous pages still valid while fetching more), and on Failure if pages were already loaded. A page fetch failure does not wipe existing pages. It returns false on Idle and Cancelled.
Fetch state
| Method | Returns | Notes |
|---|---|---|
is_fetching_next_page() | bool | Next-page fetch in flight |
is_fetching_previous_page() | bool | Previous-page fetch in flight |
is_loading() | bool | Any fetch in flight |
status() | QueryStatus | Current lifecycle status |
error() | Option<&E> | Most recent error |
Lifecycle methods
begin_fetch_next() and begin_fetch_previous() start a page fetch. They return Option<RequestId> (or None if the direction flag is false or a fetch is already in flight under IgnoreWhileLoading). Under LatestWins, an existing fetch is cancelled and replaced.
The _with_id variants accept a pre-allocated RequestId from the QueryClient bucket's sequencer to maintain monotonic IDs across the resource's lifetime. The _auto variants use an internal sequencer stored on the resource.
complete_page_success(request_id, page, has_more, is_next, now_ms) finishes a successful fetch. If the request ID matches the active request, the page is appended (next) or prepended (previous). Returns true if accepted, false if stale (the request was replaced). Passing is_next = true appends; is_next = false prepends.
complete_page_failure(request_id, error) records a failure. Existing pages are preserved.
let mut seq = RequestSequencer::new();
let id = resource.begin_fetch_next(&mut seq, now_ms);
// Later, when the async fetch completes:
let accepted = resource.complete_page_success(id, page_data, true, true, now_ms);
if !accepted {
// Request was replaced by a newer one. Discard this result.
}
Two-phase completion
For cases where you need to inspect or transform data between validation and completion:
// Phase 1: Accept the request, getting a guard
let guard = resource.accept_current_request(request_id);
if let Some(guard) = guard {
// Phase 2: Use the guard to complete
resource.complete_success_with_guard(&guard, page_data, has_more, is_next, now_ms);
// Or on failure:
// resource.complete_failure_with_guard(&guard, error);
}
The guard proves the request was still current at acceptance time. This is the same pattern used by Queries on QueryResource.
Page management
append_page(page) adds a page to the end. prepend_page(page) adds to the front. Both return Vec<T> of evicted pages when max_pages is exceeded.
set_max_pages(max) changes the cap. Returns evicted pages immediately if the current count exceeds the new limit. A value of Some(0) is treated as unbounded (None) to prevent accidentally draining all pages.
set_has_next_page(bool) and set_has_previous_page(bool) set the direction flags manually.
Reset and invalidate
reset() clears all pages, errors, and fetch state. The resource returns to Idle. The max_pages setting is preserved. The has_next_page flag resets according to FetchDirection: true for ForwardOnly, false for Bidirectional.
invalidate() clears the last_updated_at timestamp without touching pages. The data stays in memory but is considered stale, so the next access triggers a refetch.
FetchDirection
pub enum FetchDirection {
ForwardOnly, // default
Bidirectional,
}
Controls the default assumptions for has_next_page and has_previous_page after construction and after reset().
ForwardOnly (the default) sets has_next_page to true and has_previous_page to false. This is the right choice for feed-style pagination where you only append.
Bidirectional sets both to false. The query will not fetch in either direction until you explicitly call set_has_next_page(true) or set_has_previous_page(true), or until a successful fetch returns has_more = true.
InfiniteQueryOptions
Builder for configuring the hook. All methods return Self for chaining.
let opts = InfiniteQueryOptions::new(QueryKey::from(["feed"]))
.max_pages(10)
.cache_policy(CachePolicy::Ttl { ttl_ms: 300_000 })
.request_policy(RequestPolicy::LatestWins)
.retry_policy(RetryPolicy::new(3))
.gc_time(600_000);
| Field | Default | Description |
|---|---|---|
key | (required) | Cache key via QueryKey |
max_pages | Some(50) | Max pages retained. Oldest evicted on overflow |
cache_policy | CachePolicy::default() | TTL, stale-while-revalidate, or no cache |
request_policy | RequestPolicy::default() | LatestWins or IgnoreWhileLoading |
retry_policy | RetryPolicy::default() | 3 retries with exponential backoff |
gc_time_ms | 300_000 | Garbage collection time in milliseconds |
max_pages vs unbounded_pages
// Cap at 20 pages
InfiniteQueryOptions::new(key).max_pages(20)
// No limit (use with caution)
InfiniteQueryOptions::new(key).unbounded_pages()
The default cap is 50 pages. This prevents unbounded memory growth when a user scrolls through thousands of items. When the cap is exceeded, the oldest pages are evicted. For forward pagination, pages drop from the front. For backward pagination, pages drop from the back.
Call unbounded_pages() only if you have a concrete reason to keep all pages in memory and understand the tradeoff.
Request IDs and staleness
Every fetch gets a unique RequestId from a monotonically increasing sequencer. The completion methods (complete_page_success, complete_page_failure) check whether the ID still matches the active request. If it does not match, the result is discarded and ignored_results is incremented.
This matters under RequestPolicy::LatestWins. When a user scrolls fast and triggers multiple fetches, each new fetch cancels the previous one. Late-arriving responses from cancelled fetches are silently dropped.
Rendering state
A typical render method reads the entity and branches on status:
impl Render for FeedView {
fn render(&mut self, cx: &mut ViewContext<Self>) -> impl IntoElement {
self.feed.read_with(cx, |resource, _| {
match resource.status() {
QueryStatus::LoadingEmpty => "Loading first page...",
QueryStatus::LoadingWithData => {
// Show existing pages with a loading indicator at the bottom
render_pages(resource.pages())
}
QueryStatus::Success => render_pages(resource.pages()),
QueryStatus::Failure => {
if resource.is_page_data_valid() {
// Previous pages still visible, show error alongside them
render_pages_with_error(resource.pages(), resource.error())
} else {
"Failed to load"
}
}
_ => "Idle",
}
})
}
}
The key detail: Failure does not mean "no data." It means the last page fetch failed. Previously loaded pages remain accessible and valid. Check is_page_data_valid() before deciding what to show.