Docs

Shared state

Share typed synchronous browser state across independent RSJS owners.

Open Markdown

signal(...) belongs to one hydrated owner, so two independent owners cannot read the same cell. This page covers defining a schema, providing it, acquiring fields, provider resolution, and the provider grammar.

Defining a schema

Use typed SharedState when independent owners on the same page must synchronously read and write the same UI state. Derive rsjs::SharedState on an owned struct with named fields and assign a stable, namespaced protocol key:

src/state/workbench.rs
#[derive(rsjs::SharedState)]
#[rsjs(type_key = "acme.workbench")]
pub struct WorkbenchState {
    pub mode: String,
    pub button_bg: String,
    pub show_grid: bool,
}

Good to know: SharedState is not a server cache and does not persist automatically. It holds browser-owned state for one response epoch and navigation generation. Use a query or mutation when the source of truth is server-owned, asynchronous, stale, refetchable, or persistent.

The derive generates typed field accessors, client codecs, and a canonical schema ABI. The explicit type_key is the browser/server identity; a Rust module path or TypeId is not.

Changing a field name, order, type, nested codec, or integer-width rule changes the schema ABI. Two reachable declarations with the same type_key but different ABIs fail during linking. The runtime also validates the complete page table before attaching any owner, so mixed schema metadata cannot leave a page partially activated.

A schema reaches the browser only through a provider, which you place next.

Providing state to a subtree

SharedStateProvider establishes server-side topology. It does not render a DOM wrapper and is not itself a client origin. A provider that has no reachable consumer emits no browser state.

src/pages/workbench.rs
pub struct WorkbenchPage;
// Its `#[rsjs(component)]` WebRenderSync impl renders the consumer components.
pub struct WorkbenchChildren {}

#[rsjs(component)]
impl<L: DataLoader> WebRenderSync<L> for WorkbenchPage {
    fn render(self, cx: &mut WebContext<L>) -> Result<(), WriteError> {
        html_sync! {
            {rsjs::SharedStateProvider {
                state: WorkbenchState {
                    mode: "select".to_string(),
                    button_bg: "#3366cc".to_string(),
                    show_grid: true,
                },
                children: WorkbenchChildren {},
            }}
        }
        .render(cx)
    }
}

WorkbenchChildren can render several consumer components inside its own analyzed render body. Keeping the provider's direct edge explicit lets the linker authenticate the provider occurrence and every consumer that resolves through it.

Each of those consumers reaches the store by acquiring it.

Acquiring and using fields

Acquisition is the client origin. Call shared::<T>() in an origin-capable #[rsjs(component, client)] prelude, then keep field handles in named locals:

src/components/theme_panel.rs
#[rsjs(component, client)]
impl<L: DataLoader> WebRenderSync<L> for ThemePanel {
    fn render(self, cx: &mut WebContext<L>) -> Result<(), WriteError> {
        let workbench = rsjs::shared::<WorkbenchState>();
        let mode = workbench.mode();
        let button_bg = workbench.button_bg();
        let show_grid = workbench.show_grid();
        let is_editing = rsjs::computed(|| mode.get() == "edit");
        let choose_edit = rsjs::event_handler(
            |_: rsjs::MouseEvent<rsjs::elem::Button>| {
                rsjs::batch(|| {
                    mode.set("edit".to_string());
                    button_bg.set("#2f6b4f".to_string());
                });
            },
        );

        html_sync! {
            <section>
                <input type="color" bind_value={button_bg} />
                <input type="checkbox" bind_checked={show_grid} />
                <button type="button" on_click={choose_edit()}>
                    {if is_editing.get() { "Editing" } else { "Edit" }}
                </button>
            </section>
        }
        .render(cx)
    }
}

Each top-level field is an independently invalidating cell with .get(), .set(...), and .update(...). Field handles work in text, attributes, conditionals, computed values, handlers, and bind_value / bind_checked bindings. Updating one field does not invalidate consumers of unrelated fields.

The provider declaration remains server topology. shared::<T>() is what makes the consumer and its exact provider seed browser-reachable, so the consumer requires client permission.

With several providers on a page, lexical position decides which store a consumer gets.

Resolving a provider

Resolution is lexical during server rendering:

The linker records the exact provider occurrence, schema ABI, and consumer binding. The browser does not repeat a nearest-provider search through DOM ancestry and does not fall back to another provider with the same type_key.

An owner that hydrates later observes the store's current browser value. A replayed identical seed never resets a store that an earlier owner has already mutated.

Making a provider live also puts its whole value on the page.

Treating seed data as public

Provider registration and seed exposure are separate. A provider remains private when no committed or prepared owner can acquire it. Once one reachable consumer makes the provider live, Proa serializes the complete state value into the page-level rsjs-shared-state-seed-table. That table includes fields the consumer does not read.

Treat every field as public page data. Never put secrets, authorization policy, database handles, session internals, or server-only capability values in SharedState. Browser writes also do not synchronize back to Rust or grant authority. Persist changes through a validated server mutation.

Once the store is live, group related writes so the page repaints once.

Batching writes

Use rsjs::batch(...) when several synchronous writes form one UI update:

src/components/theme_panel.rs
rsjs::batch(|| {
    mode.set("edit".to_string());
    button_bg.set("#2f6b4f".to_string());
    show_grid.set(true);
});

The batching contract is:

Ordinary RSJS signal writes use the same scheduler, so batching can combine local and SharedState writes when they participate in one interaction.

Those writes survive exactly as long as the page generation that owns them.

Managing store lifetime

Stores live for the complete response epoch and navigation generation. Removing one owner or router subtree disposes its subscriptions. It does not destroy a provider still needed by the shell or by an owner that may hydrate later.

Full page-generation teardown destroys stores, field subscriptions, pending flushes, and owner references, and invalidates old handles. Query entries follow the same page-generation boundary but remain a separate async cache with request, staleness, loading, error, and invalidation semantics.

That deterministic lifetime depends on a provider shape the linker can pin.

Writing a valid provider

The v1 provider intrinsic is intentionally strict inside an analyzed RSJS template:

RuleRequired shape
StateOne named #[derive(rsjs::SharedState)] struct literal. The compiler rejects constructor calls, opaque values, nested providers, and struct-update syntax.
ChildrenExactly one direct component struct literal, such as children: WorkbenchChildren {}. Put multi-child markup inside that component.
TopologyThe provider must be an unconditional static lexical site. RSJS does not support providers inside loops, reactive conditionals, repeated/keyed regions, or deferred boundaries.
Seed dependenciesProvider state cannot capture a signal, query, mutation, SharedState handle, node ref, handler, browser value, or live inbound prop. The linker rejects a linked live input instead of sampling it once.
Child edgeThe direct child receives static server inputs only. It cannot carry enclosing client state or an inbound self field through the provider boundary. Acquire SharedState inside the child instead.

These restrictions make provider occurrence identity deterministic. Move ordinary server calculations before the template and use their results in the named state literal. If data must remain synchronized after hydration, make SharedState the source of truth or update it explicitly from a handler.

Choosing the state model

NeedUse
State private to one ownersignal(...)
Live value/callback through directly composed componentsA normal linked prop or rsjs::Handler<T>
Synchronous UI state shared by independent ownersSharedStateProvider + shared::<T>()
Server-owned async data, refetching, staleness, or loading/error statequery_with_initial(...)
Persist a browser action and invalidate server datamutation(...)

Next steps

Search

Type at least 2 characters