Docs

Hydration strategies

Defer RSJS island hydration until it matters.

Open Markdown

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.

src/components/cart_button.rs
#[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.

src/components/below_fold_filters.rs
#[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)
    }
}
StrategyUse forHydrates when
immediate or no strategyAbove-the-fold controls, nav, search, cart buttonsDOM is ready.
visibleBelow-the-fold filters, related products, large accordionsThe final owner root enters the viewport.
idleNice-to-have enhancements that do not block useThe browser has idle time.
interactionWidgets that wake on the first click, keydown, or focusFirst pointer, keyboard, or focus event on the final owner root.
interaction:clickControls where one event is the triggerThe named event fires on the final owner root.
media:(min-width: 768px)Desktop-only or media-query-gated UIThe 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:

RelationshipOwner result
Live prop, live callback, client handle, or parent-reactive lifecycle edgeThe connected occurrences must fuse into one owner.
Static nesting between disconnected client subgraphsThe 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 fuseCompilation fails with the conflicting strategies and coupling path.
One constrained occurrence plus unconstrained fused participantsThe final owner uses the constrained strategy.
No occurrence in an active owner supplies a constraintThe final owner defaults to eager/immediate hydration.
A final ServerOnly result with no reachable browser obligation or coupling to oneIt 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.

src/components/search_box.rs
#[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.

src/components/desktop_inspector.rs
#[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?

StrategyBrowser primitiveFallback
visibleIntersectionObserverHydrates immediately when unavailable.
idlerequestIdleCallbackUses setTimeout(..., 0) when unavailable.
interactionCapture-phase DOM listener on the final owner rootHydrates 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:

index.html
<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?

RuleWhy
Keep critical controls immediateUsers would otherwise wait for a trigger before primary navigation or form controls work.
Use visible for below-the-fold UIIt avoids hydration work for content the user never reaches.
Use idle for optional polishIt yields to initial rendering and input.
Use interaction for wake-on-use widgetsAfter its eagerly loaded module is ready, the triggering event reaches the installed handler exactly once.
Use media for layout-specific islandsIt avoids hydrating desktop-only or mobile-only controls on the wrong viewport.

Next steps

Search

Type at least 2 characters