日本語

Quick start

Build a counter that counts in the browser and resets on the server, in one Rust file.

Add the dependencies

Add hypersig to Cargo.toml with axum, serde, and tokio:

Cargo.toml
[dependencies]
hypersig = "0.2"
axum = "0.8"
serde = { version = "1", features = ["derive"] }
tokio = { version = "1", features = ["macros", "rt-multi-thread", "net"] }

Write the server

Write src/main.rs. It holds the page, the handler that answers calls from the reset button, and the router that serves both.

src/main.rs
use axum::Router;
use hypersig::html::{button, div, span};
use hypersig::{Action, Page, Patches, Route, RouterExt, Signals};
use serde::{Deserialize, Serialize};

#[derive(Signals, Serialize, Deserialize)]
struct Counter {
    count: i32,
}

hypersig::routes! {
    fn pages(Router<()>);
    const INDEX: Route = "/" => index;
}

const RESET: Action<Counter> = Action::post("/reset");

async fn index((): (), (): ()) -> Page<Counter> {
    let count = Counter::count();
    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 on the server")),
    )
}

async fn reset((): (), (): ()) -> Patches<Counter> {
    Counter::count().patch(0).into()
}

#[tokio::main]
async fn main() -> std::io::Result<()> {
    let app = pages(Router::new()).hypersig_runtime().action(RESET, reset);
    let listener = tokio::net::TcpListener::bind("127.0.0.1:3000").await?;
    axum::serve(listener, app).await
}

Run it

cargo run

Open http://127.0.0.1:3000. The +1 button counts in the browser without a request. The reset button calls /reset, and the handler's patch sets count back to 0. hypersig_runtime() serves the browser runtime from the crate, so no Node or JavaScript build is involved.

What each part does

  • #[derive(Signals)] on Counter adds Counter::count(), a Sig<Counter, i32> that names the field. A misspelled field is a compile error.
  • count.set(count + 1) is a statement. on_click writes it into a data-hs-on-click attribute as JSON, and the runtime runs it on each click.
  • span().bind_text(count.to_text()) keeps the text equal to the count. Page::render evaluates it with the initial values, so the HTML already shows 0.
  • INDEX is a Route, the page at /. routes! declares it together with its handler index, the function pages it writes serves it, and a link to it is INDEX.link(). Pages and links covers routes with parameters.
  • RESET is an Action<Counter>. The page calls it with RESET.call() and the router serves it with .action(RESET, reset), so both sides share its path, its method, its argument type, and the page it belongs to.
  • The handler returns Counter::count().patch(0). The server sends it as a Server-Sent Event, and the runtime writes the value into the signal, which rewrites the text.

Try it here

The counter below runs the browser side of the program on this page. It uses buttons from hypersig_ui, the component crate in the same repository.

Result

counter.rs
use hypersig::html::{div, span};
use hypersig::style::Style;
use hypersig::style::pattern::hstack;
use hypersig::style::value::{FontWeight, TextAlign, rem};
use hypersig::{Page, Signals};
use hypersig_ui::{ButtonSize, ButtonVariant, button};
use serde::{Deserialize, Serialize};

#[derive(Signals, Serialize, Deserialize)]
pub struct Counter {
    count: i32,
}

pub fn page() -> Page<Counter> {
    let count = Counter::count();
    Page::new(
        "Counter",
        Counter { count: 0 },
    )
    .child(
        div()
            .css(hstack(rem(1)))
            .child(
                button(ButtonVariant::Outline, ButtonSize::Default)
                    .on_click(count.set(count - 1))
                    .text("-1"),
            )
            .child(
                span()
                    .css(
                        Style::new()
                            .min_width(rem(3))
                            .text_align(TextAlign::Center)
                            .font_size(rem(1.5))
                            .font_weight(FontWeight::Number(600.0)),
                    )
                    .bind_text(count.to_text()),
            )
            .child(
                button(ButtonVariant::Default, ButtonSize::Default)
                    .on_click(count.set(count + 1))
                    .text("+1"),
            ),
    )
}
This site is a set of static files on GitHub Pages, so its demos run on signals alone and none of them calls an action.

Next, Building the page shows how the HTML is built.