Rust for Rust, JS for JS: introducing Ahoi

rust dev.to

Why I built Ahoi

When you want to manage data state in Rust/Wasm inside a web app, there have
been two main options.

Option 1: hand-roll the bridge between Rust and JS. The reactivity does not
live on the Rust/Wasm side, so you usually end up reimplementing the state layer
on both sides.

Option 2: use a Rust frontend framework such as Dioxus, Sycamore, or Leptos.
I like these a lot, but there is a recurring pain point: sometimes JS is just better for
web dev. The large ecosystem of JS UI components is out of reach, and some
HTML/JS native features like event handling feel awkward from a Rust framework.

I kept zigzagging between these two paths. Then the idea for Ahoi came up: go
"Rust for Rust, JS for JS". Keep reactive state management in Rust, and build a
thin bridge to communicate between Rust and JS. Then you can use whatever JS
framework you want for the UI while the core data state stays reactive in Rust.

What Ahoi is

Ahoi has two parts:

  • A Rust crate: the core reactivity engine.
  • npm packages: thin bridges, one per supported JS framework (SolidJS, React, Vue, Svelte).

I built the reactivity engine myself, but I owe a lot to pioneers like Dioxus,
Leptos, and Sycamore.

A quick look

A counter is small, but it shows the whole round trip: a value pushed from
Rust, a write sent back, a command, and a derived value recomputed in Rust.

On the Rust side, state lives in a reactive Stock, and a key enum declares
what JS is allowed to subscribe to. Each #[ret(..)] is the type JS gets back.

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

#[derive(Rets, Serialize, Deserialize)]
pub enum Hail {
    #[ret(i32)]
    Count,
    #[ret(i32)]
    Doubled,
}

fn run_hail(key: Hail) -> JsValue {
    let state = use_context::<Stock<State>>().unwrap();
    match key {
        // read-write: JS can write straight back into the stock
        Hail::Count => state.count().set_hail::<Converter>(),
        // read-only, recomputed only when `count` actually changes
        Hail::Doubled => state.count().memo(|c| *c * 2).set_read_hail::<Converter>(),
    }
}
Enter fullscreen mode Exit fullscreen mode

A Tell is a command: JS asks Rust to do something, and Rust owns the logic.

#[derive(Rets, Serialize, Deserialize)]
pub enum Tell {
    #[ret(i32)]
    Increase,
}

fn run_tell(tell: Tell) -> JsValue {
    let state = use_context::<Stock<State>>().unwrap();
    match tell {
        Tell::Increase => {
            let mut count = state.count().write();
            *count += 1;
            serde_wasm_bindgen::to_value(&*count).unwrap()
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

On the JS side those keys resolve to fully typed signals of the host framework.
SolidJS here, but the shape is the same for React, Vue, and Svelte:

import { usePier } from "./bridge";

export default function Counter() {
  const pier = usePier();
  const [count, setCount] = pier.hail("Count"); // writable signal, () => number
  const doubled = pier.readHail("Doubled");     // read-only, recomputed in Rust

  return (
    <div>
      <p>count: {count()} · doubled: {doubled()}</p>
      <button onClick={() => setCount(count() + 1)}>+1 (write)</button>
      <button onClick={() => pier.tell("Increase")}>+1 (tell)</button>
    </div>
  );
}
Enter fullscreen mode Exit fullscreen mode

Keys are plain values ("Count", no constructors), and count() is number,
not unknown. Ahoi is not a Rust to TypeScript converter: bring your own
(ts-rs, Tsify, ...). Ahoi only adds what those cannot know, which is what each
key returns.

The one-time bridge wiring (wasm init, PierProvider, type export) is covered
in the Quick Start.

Try it

This is an early release. Feedback and issues are very welcome.

Source: dev.to

arrow_back Back to Tutorials