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>(), } } 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() } } } 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> ); } 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 GitHub: https://github.com/acheul/ahoi Book and live demos: https://acheul.github.io/ahoi/ crates.io: https://crates.io/crates/ahoi npm: https://www.npmjs.com/package/@acheul/ahoi-js This is an early release. Feedback and issues are very welcome.