Docs
Queries and mutations
Seed browser queries from server-rendered data, refetch them, and run invalidating mutations.
An RSJS query starts from the value the server already rendered, so the first paint and the browser cache agree. This page covers seeding a query, awaiting the initial value, query keys, reads, and mutations.
Query and mutation URLs use normal browser resolution by default: /api/items
targets the origin root even if the app is mounted under /apps/demo. For an
endpoint served by the mounted app, set FetchRequest.apply_base_path: true;
the runtime then requests /apps/demo/api/items. Keep that endpoint's server
route unprefixed. Absolute URLs and already-mounted URLs remain unchanged.
For a simple JSON GET or POST, pass cx.url("/api/items") directly as the
query or mutation request argument. Use a literal path in that direct call. It
uses the same mount behavior without a FetchRequest descriptor. Use
FetchRequest when request options are needed.
This works when the runtime is served with FrameworkBuilder::with_rsjs_assets()
and with_base_path(...). Custom runtime handlers configure the source with
rsjs::runtime_assets::with_base_path.
See runtime wiring.
Seeding a query from server data
Use a query when the server renders data first and the browser later observes, refetches, or invalidates it. Use a mutation when a browser action writes to the server and updates related query entries.
query_with_initial(...) does not make rendering asynchronous by itself. If
the initial serde::Serialize value is already available, keep the component on
the synchronous fast path:
use proa::{DataLoader, WebContext, WebRenderSync, WriteError};
use proa::html_sync;
use rsjs::{event_handler, rsjs, QueryKey};
#[derive(Debug, Clone, serde::Deserialize, serde::Serialize)]
pub struct StockPrice {
pub value: f64,
pub currency: String,
}
pub struct StockTile {
pub initial: StockPrice,
}
#[rsjs(component, client)]
impl<L: DataLoader> WebRenderSync<L> for StockTile {
fn render(self, cx: &mut WebContext<L>) -> Result<(), WriteError> {
let price: rsjs::Query<StockPrice> = rsjs::query_with_initial(
QueryKey::new(("stock", "AAPL")),
self.initial,
rsjs::FetchRequest {
url: "/api/stocks/AAPL",
apply_base_path: true,
response: Some(rsjs::ResponseKind::Json),
headers: &[("Accept", "application/json")],
timeout_ms: Some(1_500),
..Default::default()
},
rsjs::QueryOptions {
refetch_on_focus: true,
stale_time_ms: 2_000,
..Default::default()
},
);
let refresh = event_handler(|_: rsjs::MouseEvent<rsjs::elem::Button>| {
price.refetch();
});
html_sync! {
<article>
<span>{price.data().value}</span>
<span>{price.data().currency}</span>
<button type="button" on_click={refresh()}>"Refresh"</button>
</article>
}
.render(cx)
}
}
The initial value is ordinary server Rust. RSJS serializes it into the response's page-level query seed table, and the adjacent island metadata names the effective key. The server-rendered output and browser cache therefore begin with the same value.
When producing that value needs an .await, the component moves to the async path covered next.
Awaiting the initial value
Use WebRender and html! only when obtaining the initial value actually needs
.await, such as a DataLoader, database, or service call. Author the real
render trait and return RenderOutcome::pending(async move { ... }). RSJS does
not support async fn render shorthand.
use std::future::Future;
use proa::{
DataLoader, LoadKey, RenderOutcome, WebContext, WebRender, WriteError,
};
use proa::html;
use rsjs::{rsjs, QueryKey};
use crate::components::stock_tile::StockPrice;
pub struct AsyncStockTile;
#[rsjs(component, client)]
impl<L: DataLoader> WebRender<L> for AsyncStockTile {
fn render(
self,
cx: &mut WebContext<L>,
) -> RenderOutcome<impl Future<Output = Result<(), WriteError>>> {
RenderOutcome::pending(async move {
let initial: StockPrice = cx.load_json(LoadKey::named("stock", "AAPL")).await?;
let price: rsjs::Query<StockPrice> = rsjs::query_with_initial(
QueryKey::new(("stock", "AAPL")),
initial,
rsjs::FetchRequest {
url: "/api/stocks/AAPL",
apply_base_path: true,
response: Some(rsjs::ResponseKind::Json),
..Default::default()
},
rsjs::QueryOptions::default(),
);
html! {
<span>{price.data().value}</span>
}
.render(cx)
.resolve()
.await
})
}
}
The important boundary is that the load happens through cx before its result
enters query_with_initial(...).
Do not create a query only to fetch SSR data. If the browser will not observe, refetch, invalidate, or bind the value, load it as ordinary server data and render it without a query runtime.
Every query, sync or async, reaches its cache entry through a key, which the next section covers.
Choosing query keys
Use QueryKey::new((...)) with tuple parts. Tuples support mixed Rust types and
serialize to the same structural array shape used by the browser cache.
QueryKey::new(("stock", "AAPL"))
QueryKey::new(("product", product_id, "reviews"))
QueryKey::new(("search", query.get()))
The key identifies the cache entry. The FetchRequest describes the browser
request and stays static. Keys do not substitute URL parameters or send their
parts to the endpoint. Keep the server seed, cache key, and literal request
descriptor aligned with the same resource; changing only the key does not change
the URL or request body.
When a linked parent supplies a live value used in a child query key, RSJS keeps the query handle stable. It rebinds the observer to the entry for the new structural key, and it does not rename or mutate the old cache entry. Existing subscriptions move to the new entry.
Good to know: a request already in flight stays owned by the old entry.
The next section covers what belongs in the descriptor next to the key.
Describing fetch requests
FetchRequest is the client request descriptor. Keep dynamic state in the key
or mutation payload; keep the endpoint shape in the descriptor.
rsjs::FetchRequest {
url: "/api/stocks/AAPL",
apply_base_path: true,
method: Some(rsjs::HttpMethod::Post),
credentials: Some(rsjs::FetchCredentials::SameOrigin),
response: Some(rsjs::ResponseKind::Json),
headers: &[("Accept", "application/json"), ("X-RSJS", "1")],
body: Some(rsjs::RequestBody::Json(r#"{"symbol":"AAPL"}"#)),
timeout_ms: Some(1_500),
}
Available response decoders are Json, Text, Bytes, and Empty.
QueryOptions also supports retry, polling, staleness, focus refetching,
download progress, and streaming text for text responses.
Once a query exists, components read it reactively, which the next section shows.
Reading query state
Query handles are reactive. Use them in text, attributes, conditionals, and handlers. Use the template macro that matches the component's render trait.
let invalidate = event_handler(|_: rsjs::MouseEvent<rsjs::elem::Button>| {
price.invalidate();
});
html_sync! {
<div>
<span>{price.status()}</span>
<span>{price.data().value}</span>
<span>{price.download_percent()}</span>
{if price.is_refetching() {
html_sync! { <span>"Refreshing"</span> }
}}
<button type="button" on_click={invalidate()}>"Invalidate"</button>
</div>
}
Common reads:
| Read | Meaning |
|---|---|
query.data().field | Project typed JSON data. |
query.status() | Current status string. |
query.is_loading() / query.is_success() / query.is_error() | Status flags. |
query.is_refetching() / query.is_stale() | Cache state flags. |
query.error() | Error value projection. |
query.download_loaded() / query.download_total() / query.download_percent() | Progress values when enabled. |
Several owners can read one key at the same time, so the next section covers who owns the entry.
How long do cache entries and observers live?
Declarations with the same structural key share one page-scoped cache entry and
one in-flight request. Options such as stale_time_ms remain observer-local.
Active polling observers share the smallest positive interval. Focus refetching
runs when any active observer that requested it considers the entry stale.
Removing an owner disposes its observer and subscriptions, but does not evict the cache entry. A later owner in the same response epoch and navigation generation can reuse it. Full page-generation teardown cancels scheduled work and in-flight query requests, invalidates old handles, and clears the cache.
Writes reach those entries through mutations, which the next section covers.
Running mutations
Mutations are async-only. Declare them inside the canonical WebRender pending
future and render with html!. The mutation key identifies the write operation;
the invalidation list names the query keys the runtime refreshes after a
successful run.
use std::future::Future;
use proa::{DataLoader, RenderOutcome, WebContext, WebRender, WriteError};
use proa::html;
use rsjs::{rsjs, QueryKey};
pub struct CartEditor;
#[rsjs(component, client)]
impl<L: DataLoader> WebRender<L> for CartEditor {
fn render(
self,
cx: &mut WebContext<L>,
) -> RenderOutcome<impl Future<Output = Result<(), WriteError>>> {
RenderOutcome::pending(async move {
let draft = rsjs::signal(String::new());
let save = rsjs::mutation::<String, ()>(
QueryKey::new(("cart", "update")),
rsjs::FetchRequest {
url: "/api/cart",
apply_base_path: true,
method: Some(rsjs::HttpMethod::Patch),
credentials: Some(rsjs::FetchCredentials::SameOrigin),
response: Some(rsjs::ResponseKind::Empty),
timeout_ms: Some(2_500),
..Default::default()
},
&[QueryKey::new(("cart", "summary"))],
);
let update_draft = rsjs::event_handler(
|event: rsjs::InputEvent<rsjs::elem::Input>| {
draft.set(event.current_value());
},
);
let submit = rsjs::event_handler(
|_: rsjs::MouseEvent<rsjs::elem::Button>| {
save.run(draft.get());
},
);
html! {
<div>
<input value={draft.get()} on_input={update_draft()} />
<button
type="button"
disabled={save.is_pending()}
on_click={submit()}
>
{if save.is_pending() { "Saving..." } else { "Save" }}
</button>
</div>
}
.render(cx)
.resolve()
.await
})
}
}
run(args) sends one JSON value when the request descriptor has no fixed body.
A Rust String becomes a JSON string; text containing JSON is not parsed into
an object. Match the endpoint's input type to the payload type. A fixed
FetchRequest.body takes precedence over the run argument.
Use mutation_with_options(...) when you need retry policy or a different
invalidation strategy.
let save = rsjs::mutation_with_options::<String, ()>(
QueryKey::new(("cart", "update")),
rsjs::FetchRequest {
url: "/api/cart",
apply_base_path: true,
method: Some(rsjs::HttpMethod::Patch),
response: Some(rsjs::ResponseKind::Empty),
..Default::default()
},
&[QueryKey::new(("cart", "summary"))],
rsjs::MutationOptions {
retry_count: 1,
on_success_invalidate_strategy: rsjs::InvalidationStrategy::Refetch,
},
);
The table below condenses these choices into one pass.
When to use each form?
| Rule | Why |
|---|---|
Use WebRenderSync when the query initial already exists | Declaring a query does not require an await. |
Use WebRender only for an awaited query initial or a mutation | The render trait reflects actual async work and the current mutation contract. |
| Seed query data on the server | The first paint then matches the hydrated cache. |
| Use tuple query keys | They handle mixed key parts and preserve structural identity. |
Keep FetchRequest static | Runtime request shape belongs to the component definition. |
| Invalidate related query keys after writes | Otherwise the browser cache can show stale data. |
Next steps
- Handlers
- Type the events that call
refetch(),invalidate(), andrun().
- Type the events that call
- Signals
- Hold local client state such as a mutation draft.
- Hydration strategies
- Choose when an owner attaches its query and mutation runtime work.
- Data loading
- Load the initial value on the server through
cx.
- Load the initial value on the server through
- Component props
- Pass live values that rebind a child query key.