How Topcoat Implements Client-Side Reactivity Without JavaScript Bundles
Topcoat achieves zero-bundle client-side reactivity by compiling Rust expressions into JavaScript at compile time and injecting a single, self-contained runtime script that manages reactive signals and DOM updates directly in the browser.
The tokio-rs/topcoat framework eliminates traditional JavaScript build pipelines by embedding a minimal runtime and generating executable client code directly from Rust macros. This architecture allows developers to write interactive server-rendered applications without WebAssembly or separate frontend bundling steps.
The Single-Script Runtime Architecture
Topcoat’s reactivity depends on a tiny browser script shipped as a static asset. The function topcoat::runtime::script() inserts a <script type="module"> tag that loads browser/dist/index.js from the runtime crate. This file, declared in crates/topcoat-runtime/src/lib.rs as the SCRIPT asset, constitutes the only JavaScript delivered to the client.
To enable the runtime, register the asset bundle with your router using AssetBundle::load():
use topcoat::runtime::AssetBundle;
let app = Router::new()
.merge(AssetBundle::load());
The script is served directly without modification, tree-shaking, or additional compilation, ensuring immediate execution upon page load.
Compile-Time JavaScript Generation
When the view! macro encounters a $(…) block, the expr! procedural macro parses the Rust expression and generates dual representations. Located in crates/topcoat-runtime/grammar/src/expr/, this parser creates:
- A Rust expression executed during server-side rendering
- A JavaScript snippet embedded directly into the HTML response
For example, writing $(count.get()) inside a template causes the macro to emit equivalent JavaScript that reads from a reactive signal. This happens entirely at compile time, removing the need for runtime bundling or translation.
Signal Serialization and Hydration
State management relies on the Signal<T> type defined in crates/topcoat-runtime/src/signal.rs. When a component declares signal count = 0.0, the server creates a Signal instance and serializes its initial value into an HTML comment:
<!--::topcoat::signal({"id":"count-0","value":0})-->
The runtime script scans for these comments during hydration, reconstructs the signals as JavaScript objects, and stores them in a reactive map. This bridges server-rendered state with client-side interactivity without JSON endpoints or additional network requests.
Reactive DOM Updates Without Network Round-Trips
Runtime expressions register as watchers on the signals they access. When code calls .set(), .increment(), or other mutators, the runtime re-evaluates dependent expressions and patches affected DOM nodes immediately.
The client-side implementation mirrors the server’s view hierarchy, ensuring updates happen locally:
view! {
signal count = 0.0;
<button @click=$(|_e| count.increment())>"+"</button>
<p>"Current: " $(count.get())</p>
}
Here, clicking the button triggers the increment handler (compiled to JavaScript), which updates the signal and causes the paragraph text to refresh instantly.
Event Handling and Bind Attributes
Interactive attributes use prefixes to distinguish client behavior. Attributes starting with @ become event listeners whose handlers are generated by expr!. The @click attribute in the previous example compiles to a native JavaScript click listener.
Bind attributes prefixed with : synchronize DOM properties with signal values:
view! {
signal open = false;
<button @click=$(|_e| open.toggle())>"Toggle"</button>
<section :hidden=$(!open.get())>"Hidden when closed"</section>
}
The runtime keeps the hidden attribute in sync with the negated signal value, updating it reactively whenever open changes.
Calling Server Procedures from the Client
When client-side state requires server validation or computation, handlers can await async procedures. The runtime script performs the HTTP request while the UI remains responsive:
#[procedure]
async fn double(v: f64) -> Result<f64> { Ok(v * 2.0) }
view! {
signal n = 1.0;
<button @click=$(async |_e| {
let doubled = double(n.get()).await?;
n.set(doubled);
})>"Double"</button>
}
This pattern combines local reactivity with server communication without requiring separate API routes or manual fetch implementations.
Summary
- Single asset delivery: The
topcoat::runtime::script()function injects the only JavaScript file needed, located atcrates/topcoat-runtime/browser/dist/index.js. - Macro-generated JavaScript: The
expr!macro incrates/topcoat-runtime/grammar/src/expr/transforms Rust expressions into executable JavaScript during compilation. - Comment-based hydration: Signals serialize to HTML comments like
::topcoat::signal({...}), which the runtime script hydrates into reactive JavaScript objects. - Direct DOM patching: Signal mutations trigger immediate DOM updates through locally registered watchers, eliminating network latency for UI changes.
- Zero external tooling: No bundlers, WebAssembly, or build pipelines are required beyond the Rust compiler.
Frequently Asked Questions
How does Topcoat differ from WebAssembly-based frameworks?
Topcoat generates plain JavaScript through compile-time macros rather than compiling Rust to WebAssembly. According to the source code in crates/topcoat-runtime/grammar/src/expr/, the expr! macro emits JavaScript snippets directly, allowing the browser to execute native code without loading a WASM module or glue code.
Where is the client-side JavaScript actually stored?
The runtime script lives as a static byte array in crates/topcoat-runtime/src/lib.rs, embedded using include_bytes!("../browser/dist/index.js"). This asset is served by the Axum router when you add AssetBundle::load(), making it available at a predictable URL that topcoat::runtime::script() references.
Can I use existing JavaScript libraries with Topcoat?
Since Topcoat injects a single script tag and generates vanilla JavaScript expressions, you can interface with global browser APIs or external scripts loaded via standard <script> tags. However, the reactive system specifically manages state through Topcoat's Signal type, so third-party reactive libraries may require wrapper functions to integrate with the signal comment format.
What happens if JavaScript is disabled?
Topcoat applications are server-rendered by default. Without JavaScript, users receive fully functional HTML with initial signal values rendered inline. The reactive enhancements degrade gracefully to static content, maintaining accessibility and SEO benefits while losing client-side interactivity.
Have a question about this repo?
These articles cover the highlights, but your codebase questions are specific. Give your agent direct access to the source. Share this with your agent to get started:
curl -s "https://instagit.com/install.md" Maintain an open-source project? Get it listed too →