Fetching data#
leptoskit_query is TanStack Query for leptos: a cache of async state shared by every component that reads it, kept fresh in the background, and exposed through fine-grained signals. It lives in the leptoskit_query crate and behind the query feature of the leptoskit meta crate.
[dependencies]
leptoskit_query = "0.1"examples/harbor is a complete app built this way: a ferry departures board with a cached query per day, a booking that shows before the server confirms it and rolls back when it refuses, and an infinite log.
Provide a client#
One QueryClient holds every query and mutation. Provide it at the root; it also wires the cache to window focus and network events in the browser.
use leptos::prelude::*;
use leptoskit_query::prelude::*;
#[component]
pub fn App() -> impl IntoView {
QueryClient::new().provide();
// ...
}Define a scope#
Where TanStack identifies a query with an array key like ['todo', id], here the first part — which fetcher — is a typed QueryScope, and the rest is a typed key. Define scopes as plain functions; a scope's identity comes from its fetcher, so every call names the same cache entries.
use std::time::Duration;
use leptoskit_query::prelude::*;
fn todo() -> QueryScope<u32, Todo, ApiError> {
QueryScope::new(|id| async move { fetch_todo(id).await })
.with_stale_time(Duration::from_secs(30))
.with_tags_for(|id| vec![tag!["todos", id]])
}Everything the fetch depends on belongs in the key, exactly as TanStack asks of query keys. A fetcher over a server function is the common case:
fn todo() -> QueryScope<u32, Todo, ServerFnError> {
QueryScope::new(|id| get_todo(id))
}A fetcher that captures a value — an API client, a base path — needs a name, because its type cannot tell two such scopes apart. Give it one with with_name:
fn resource(path: &'static str) -> QueryScope<u32, Item, ApiError> {
QueryScope::new(move |id| fetch_item(path, id)).with_name(path)
}resource("users") and resource("posts") now keep separate caches. Without the name, the first use of such a scope panics, rather than letting every scope built from that closure share one cache.
Read it in a component#
use_query takes the scope and a reactive key. The handle it returns is Copy, like a signal, and every accessor is a reactive read.
#[component]
fn TodoView(id: RwSignal<u32>) -> impl IntoView {
let todo = use_query(todo(), move || id.get());
view! {
<Show when=move || todo.is_loading()>"Loading…"</Show>
<Show when=move || todo.is_error()>"Something went wrong"</Show>
<p>{move || todo.data().map(|todo| todo.title)}</p>
<button on:click=move |_| {
todo.refetch();
}>"Refresh"</button>
}
}A key closure may return Option<K>: None disables the query until the key is known, which is how dependent queries are written. use_query_with adds per-observer options such as keep_previous_data() for pagination, with_enabled, placeholder and initial data, or a different stale time.
Write with a mutation#
A MutationScope is useMutation's options. on_mutate runs first and whatever it returns is the context every later callback receives — the place to remember what an optimistic update should roll back to.
fn add_todo() -> MutationScope<String, Todo, ApiError, Option<Vec<Todo>>> {
MutationScope::new(|title: String| async move { create_todo(title).await })
.with_on_mutate_sync(|args| {
// A refetch already on its way would land on top of the
// optimistic list, so stop it first.
args.client.cancel_queries(QueryFilter::key(&todos(), &()));
let previous = args.client.get_query_data(&todos(), &());
args.client.update_query_data(&todos(), (), |list| {
let mut list = list.unwrap_or_default();
list.push(Todo::pending(&args.variables));
Some(list)
});
previous
})
.with_on_error(|args| {
match args.context {
Some(previous) => args.client.set_query_data(&todos(), (), previous),
// Nothing was cached before, so the optimistic list goes too.
None => args.client.remove_queries(QueryFilter::key(&todos(), &())),
}
})
.with_on_settled_async(|args| {
async move {
args.client.invalidate_scope(&todos()).await;
}
})
}use_mutation(add_todo()) returns a handle with mutate, mutate_async, is_pending, error, and the rest. An awaited on_settled keeps the mutation pending until the refetch it triggers has landed, so a form can stay disabled until the list is really up to date.
Infinite queries#
An InfiniteQueryScope pages in either direction. The fetcher receives the page param; with_next_page_param derives the next one from the pages so far.
fn feed() -> InfiniteQueryScope<UserId, Cursor, FeedPage, ApiError> {
InfiniteQueryScope::new(
|ctx| async move { fetch_feed(ctx.key, ctx.page_param).await },
Cursor::start(),
)
.with_next_page_param(|ctx| ctx.page.next_cursor.clone())
.with_max_pages(5)
}use_infinite_query returns a handle with pages, items_with(|page| &page.posts), has_next_page, fetch_next_page, and is_fetching_next_page, plus everything a plain query offers.
Invalidate#
args.client.invalidate_scope(&todos()) marks every todo query stale and refetches the ones on screen. Invalidate one key with invalidate_query, a hierarchy with invalidate_tag(tag!["users", 7]), or anything a QueryFilter can describe — by scope, by key predicate, by staleness, by fetch status.
Server rendering#
A plain query does not fetch during SSR; the browser fetches after hydration. Add .with_ssr() to a scope whose data is serializable and the server fetches it, streams it with the HTML, and the browser starts from that data, with no loading state. Read such queries inside <Suspense> with query.await, exactly as you would a leptos resource, so the server waits for the data before rendering the boundary.
Give those scopes a stale time, such as .with_stale_time(Duration::from_secs(30)). Data counts as stale the moment it is fetched unless a scope says otherwise, so without one the browser refetches it in the background right after hydrating. If you dehydrate the cache yourself with client.dehydrate() — to embed it in a page or save it to storage — name the scopes too: the browser, or the next build, is another program, and only a name identifies a scope in both.
Across tabs#
let client = QueryClient::new();
client.provide();
let _sync = client.broadcast("my-app");Every tab that joins the same channel shares cache writes: log in on one tab and the others have the user. Only scopes with .with_ssr() take part, since the data has to be serialized. Outside the browser, LocalBroadcast syncs clients in one process and BroadcastTransport adapts anything else.
Native and embedded hosts#
The library never assumes a browser. Where it runs decides who provides time and events:
- Browser — the default
browserfeature usessetTimeout,Date.now(), and the document's visibility and connectivity events. - Server and native — tokio (or a helper thread) and
SystemTimecover time; the shell reports focus and connectivity withFocusManager::set_focusedandOnlineManager::set_online, and callsclient.mount()if it does not useprovide(). - Wasm hosted outside a browser — build with
--no-default-features, theninstall_clockandinstall_sleepbefore creating a client. Everything else is unchanged.