Docs
Hydration strategies
Defer RSJS island hydration until it matters.
RSJS hydrates every island immediately unless a component asks for a later trigger. This page covers choosing a strategy, final owner planning, the interaction and media triggers, and runtime behavior.
Choosing a strategy
The server always sends real HTML first. A final owner is the linked unit RSJS activates in the browser. The strategy controls only when that owner attaches client state, event handlers, input bindings, and query/mutation runtime work.
#[rsjs(component, client)]
impl<L: DataLoader> WebRenderSync<L> for CartButton {
fn render(self, cx: &mut WebContext<L>) -> Result<(), WriteError> {
let count = signal(0_i64);
html_sync! {
<button type="button" on_click={|_| count.update(|n| n + 1)}>
"Cart ("{count.get()}")"
</button>
}
.render(cx)
}
}
Add strategy = "..." to the complete component attribute when the final owner can
wait without breaking the first user action.
#[rsjs(component, client, strategy = "visible")]
impl<L: DataLoader> WebRenderSync<L> for BelowFoldFilters {
fn render(self, cx: &mut WebContext<L>) -> Result<(), WriteError> {
let expanded = signal(false);
html_sync! {
<aside>
<button on_click={|_| expanded.set(!expanded.get())}>
"Toggle filters"
</button>
<div hidden={!expanded.get()}>"Filters"</div>
</aside>
}
.render(cx)
}
}
| Strategy | Use for | Hydrates when |
|---|---|---|
immediate or no strategy | Above-the-fold controls, nav, search, cart buttons | DOM is ready. |
visible | Below-the-fold filters, related products, large accordions | The final owner root enters the viewport. |
idle | Nice-to-have enhancements that do not block use | The browser has idle time. |
interaction | Widgets that wake on the first click, keydown, or focus | First pointer, keyboard, or focus event on the final owner root. |
interaction:click | Controls where one event is the trigger | The named event fires on the final owner root. |
media:(min-width: 768px) | Desktop-only or media-query-gated UI | The media query matches. |
RSJS accepts eager as an alias for immediate.
A strategy names a constraint, not an island boundary. The next section covers how the linker turns those constraints into final owners.
How does the linker plan final owners?
A strategy on #[rsjs(component, ...)] contributes a constraint from that
component occurrence. It does not force every component to become a separate
browser island. Once the linker knows client reachability, it partitions the
rendered component graph into final owners:
| Relationship | Owner result |
|---|---|
| Live prop, live callback, client handle, or parent-reactive lifecycle edge | The connected occurrences must fuse into one owner. |
| Static nesting between disconnected client subgraphs | The subgraphs may remain separate owners and retain separate strategies. |
rsjs::island(...) | Forces an independent-owner cut. A live edge cannot cross it. |
| Different explicit strategies on occurrences that must fuse | Compilation fails with the conflicting strategies and coupling path. |
| One constrained occurrence plus unconstrained fused participants | The final owner uses the constrained strategy. |
| No occurrence in an active owner supplies a constraint | The final owner defaults to eager/immediate hydration. |
A final ServerOnly result with no reachable browser obligation or coupling to one | It forms no owner and ignores any requested strategy. |
A server-only section can therefore contain a visible chart and an interaction-activated search box without becoming an owner itself. When nothing connects the two children, each remains its own owner. When a live prop or callback connects them, they share one owner and must agree on an explicit activation strategy.
Interaction carries the most runtime nuance of the deferred triggers, so the next section covers it first.
Hydrating on interaction
Use interaction when HTML handles the first paint and a user action activates
the owner.
#[rsjs(component, client, strategy = "interaction:focusin")]
impl<L: DataLoader> WebRenderSync<L> for SearchBox {
fn render(self, cx: &mut WebContext<L>) -> Result<(), WriteError> {
let query = signal(String::new());
html_sync! {
<label>
"Search"
<input type="search" bind_value={query} />
</label>
}
.render(cx)
}
}
Without an explicit event, interaction listens for pointerdown, keydown, and focusin.
Interaction owner modules start loading eagerly. The runtime arms capture
listeners only after the module and its dependencies evaluate successfully.
Once armed, the triggering native event initializes the owner during capture.
That event then reaches the real bubble handler exactly once, without synthetic
replay, preventDefault(), or propagation suppression.
Good to know: an interaction that happens while the module still loads keeps its normal browser behavior. It cannot invoke an RSJS handler that is not ready yet.
Use visible, idle, or media when you want the framework to delay the owner
module import itself. The next section covers the layout-driven trigger of that set.
Hydrating on a media query
Use media:<query> when the owner only matters in a matching layout.
#[rsjs(component, client, strategy = "media:(min-width: 1024px)")]
impl<L: DataLoader> WebRenderSync<L> for DesktopInspector {
fn render(self, cx: &mut WebContext<L>) -> Result<(), WriteError> {
let open = signal(false);
html_sync! {
<aside>
<button on_click={|_| open.set(!open.get())}>"Inspector"</button>
<div hidden={!open.get()}>"Desktop tools"</div>
</aside>
}
.render(cx)
}
}
If the query matches immediately, the owner hydrates immediately. If it does not match, the runtime waits for a media-query change.
Every deferred trigger leaves a window where the user can type first, and the next section covers what happens to those edits.
Keeping form state before hydration
Deferred hydration does not overwrite edits the user makes before an owner
activates. On the owner's first mount, RSJS compares form controls with their
server defaults. It adopts dirty browser state into the corresponding signal
before committing DOM updates. This covers text inputs and textareas, checked
state, select options, and editable text.
Untouched controls take the current signal value. Reconciliation for the whole owner completes before its first DOM commits. One edited control therefore updates other untouched controls bound to the same signal, without briefly restoring the old server seed.
Each strategy reaches these guarantees through a different browser primitive, listed next.
How does each trigger behave at runtime?
| Strategy | Browser primitive | Fallback |
|---|---|---|
visible | IntersectionObserver | Hydrates immediately when unavailable. |
idle | requestIdleCallback | Uses setTimeout(..., 0) when unavailable. |
interaction | Capture-phase DOM listener on the final owner root | Hydrates immediately if the root cannot receive listeners. |
media:(...) | window.matchMedia(...) | Hydrates immediately when unavailable or invalid. |
For visible, idle, and media, framework bootstraps can delay importing the
owner module until the trigger fires:
<script type="module">
import { hydrate_when_visible, defer_island_module } from "/rsjs-runtime.js";
defer_island_module(
"belowfoldfilters_...",
"/rsjs/island/belowfoldfilters_....js?rsjs-triggered=1",
hydrate_when_visible,
);
</script>
This is the bootstrap the page runtime plan emits for you; media adds the
query as a fourth argument. defer_island_module registers the module with
the runtime so the import it performs later is authorized, and the
?rsjs-triggered=1 flag tells the imported owner module not to wait a second
time. A bare dynamic import() inside the trigger callback skips that
registration.
These hydration primitives are runtime internals. Use the strategy attribute in island code, and pick between the strategies with the table below.
When to use each strategy?
| Rule | Why |
|---|---|
| Keep critical controls immediate | Users would otherwise wait for a trigger before primary navigation or form controls work. |
Use visible for below-the-fold UI | It avoids hydration work for content the user never reaches. |
Use idle for optional polish | It yields to initial rendering and input. |
Use interaction for wake-on-use widgets | After its eagerly loaded module is ready, the triggering event reaches the installed handler exactly once. |
Use media for layout-specific islands | It avoids hydrating desktop-only or mobile-only controls on the wrong viewport. |
Next steps
- Browser APIs
- The public RSJS browser API surface island code calls.
- Signals
- Declare the client state a hydrated owner attaches to.
- Bindings
- Bind form controls to signals with
bind_value.
- Bind form controls to signals with
- Server-only components
- Keep sections out of the browser so they form no owner.
- Islands
- See how islands fit into a full Proa page.