Why hypersig
hypersig renders the page on the server and writes its behavior into the HTML. Every string the browser acts on comes from a Rust type, so the page needs no browser build, no hydration, and no string typed by hand.
The server side and the browser side
A page served by a Rust server splits into a server side and a browser side. The server side builds the HTML and answers requests. The browser side reacts to clicks and typing, and the browser runs only JavaScript or wasm. Two frameworks close to hypersig keep signals in the browser and build nothing for it, and each leaves something unchecked.
- Datastar sends HTML and describes the behavior in attributes. Nothing hydrates, and the server patches elements and signals over server-sent events. The signal names, the URLs, and the expressions in the attributes are strings, so a typo in one shows up when the browser runs it.
- Topcoat writes the browser's expressions in Rust and translates them to JavaScript, so they are type-checked. Its
href!checks only the handler a link points to, and a missing or extra path parameter panics when the page renders. Its expressions cannot add an item to a list, so a list changes only when the server answers and renders it again.
hypersig builds the page as Datastar does, as HTML with attributes, and Rust writes every attribute from a typed value. A link takes its route's parameters as a typed value, and a list is a signal the browser can change before the server answers.
The behavior written in the HTML
Here is the counter from the Quick start, with a link added. HISTORY is a page whose path includes the count.
use hypersig::html::{a, button, div, span};
use hypersig::{Action, Route};
#[derive(Signals, Serialize, Deserialize)]
struct Counter {
count: i32,
}
hypersig::routes! {
fn pages(Router<()>);
const HISTORY: Route<i32> = "/history/{count}" => history;
}
const RESET: Action<Counter> = Action::post("/reset");
let count = Counter::count();
let page = Page::new("Counter", Counter { count: 0 }).child(
div()
.child(button().on_click(count.set(count + 1)).text("+1"))
.child(span().bind_text(count.to_text()))
.child(button().on_click(RESET.call()).text("Reset"))
.child(a().href(HISTORY.link_with(count)).text("History")),
);Page::render writes this body, shown here with the HTML entities decoded:
<body data-hs-signals='{"count":0}'>
<div>
<button data-hs-on-click='["set","count",["add",["sig","count"],["lit",1]]]'>+1</button>
<span data-hs-text='["str",["sig","count"]]'>0</span>
<button data-hs-on-click='["call","POST","/reset",null,null,null]'>Reset</button>
<a href="/history/0"
data-hs-attr='[["href",["concat",["lit","/history/"],["uri",["sig","count"]]]]]'>History</a>
</div>
</body>Each attribute comes from one value in the Rust code. data-hs-signals is the Counter struct, serialized. data-hs-on-click is the statement count.set(count + 1) as JSON, and count in it is the path Counter::count() names. /reset is the path of RESET, and the href comes from HISTORY. A misspelled field, a value of the wrong type, or a link with a parameter its route does not take fails to compile before any of this is written.
No hydration
The page's code is written in Rust and is not sent to the browser. When the page loads, the runtime reads the state from data-hs-signals into a store, adds one listener per event type on <body>, and reads the data-hs-* attributes. It downloads no component code and runs no component again, because the attributes already hold every expression and statement the browser runs. Like Qwik's resumability, the state and the listeners are sent in the HTML.
Page::render also evaluates each binding with the initial values, so the HTML already shows 0 and the link already points to /history/0. The page displays correctly before the runtime loads, and the runtime is the same file for every page: about 42 KB, or about 14 KB gzipped, embedded in the crate.
Typed from page to server
The rule that keeps the attributes correct is that the browser never receives a string written by hand. Each kind of string has a type that produces it:
- Signal names come from
#[derive(Signals)], which adds one typed accessor per field. - Expressions and statements are
ExprandStmtvalues, whose operators check the types of both sides. - Action URLs come from
Action<S, A>, which also fixes the page an action belongs to and the type of its arguments. - Page links come from
Route<P, Q>, declared inroutes!with the handler that serves it, so a link carries the right path parameters and query. - Patch targets come from
Fragment::ID, which the page and the patch read from the same constant. - Element names for DOM calls come from
NodeRef.
use hypersig::Signals;
use serde::{Deserialize, Serialize};
#[derive(Signals, Serialize, Deserialize)]
struct Counter {
count: i32,
}
// error[E0277]: `&str` is not an expression of type `i32` on the page whose signals are `Counter`
let reset = Counter::count().set("zero");The server owns the state
The browser keeps signals for what changes on the page, and the server keeps the data. An action answers with patches over Server-Sent Events: new signal values, or a fragment of HTML that replaces an element of the page. A statement runs in the browser at once, and an action call sent with it reaches the server after, so the page changes before the server answers without extra code. Talking to the server covers actions, patches, and streams.
The unit of fetching
In RSC and Next.js, data fetching is written with await inside server components, so each component starts its fetch separately and the results appear in arrival order, one Suspense boundary at a time. Depending on where the boundaries sit, the page fills in piece by piece, and a child whose fetch waits for its parent's await puts the fetches in series. In Remix and TanStack Start, each route's loader runs in parallel before the page is shown, but the loaders run again after a change, so toggling one checkbox fetches the page's or the list's data again.
In hypersig, the first data is already in the page's HTML as the signal struct's JSON, so the browser fetches nothing to show the page. After a change, the action's handler returns only the values of the signals that changed, as in Counter::count().patch(0), and the browser rewrites only what shows those values, so toggling one checkbox fetches neither the page nor the list again.
What the browser runs on load and on an update, compared with other frameworks. An effect is the code that reads signal values and rewrites the DOM, and hypersig has one for each display attribute such as data-hs-text or data-hs-show.
| hypersig | React | Vue | Solid | Qwik | |
|---|---|---|---|---|---|
| App code sent to the browser | None (expressions travel as data in HTML attributes) | Every component | Every component | Every component | Only the handlers used, when they are used |
| What the browser runs on load | Every effect, once each | The whole component tree | The whole component tree | Each component once, to build its reactive dependencies | Nothing (state and handler locations are in the HTML) |
| What reruns on an update | Only the effects that read the value | The component holding the state and its descendants | The component's render function, diffed through a virtual DOM | Only the effects that read the value | Only the effects that read the value |
The cost is on failure. When the server answers with an error, the change made in the browser stays, and nothing rolls it back by itself. Call::on_error runs a statement where the call was made, so the page can undo or mark the one change whose save failed.
What it costs
- The browser runs only what
ExprandStmtexpress: arithmetic, comparisons, text, lists, conditions, and calls to the server and the DOM. A computation outside that set runs on the server. When bindings and statements are not enough covers the browser features a page reaches beyond them, and the applications that need a JavaScript library and fit hypersig poorly. - Signal values cross as JSON, so a signal's type implements serde's traits, and an
i64past 2^53 loses precision in the browser. - Following a link loads the next page from the server. There is no client-side navigation that keeps the current page on screen.
- Pages are served through axum, which is the only server hypersig integrates with.
Next, the Quick start builds the counter and runs it.