| name | vue-to-rust |
| description | Use when migrating Vue.js frontends to Rust WASM โ covers SFC to Leptos/Dioxus components, ref()/reactive() to signals, v-if/v-for to Show/For, Pinia/Vuex to signal stores, vue-router to leptos_router, Vite to Trunk, and incremental WASM replacement. Includes canonical code patterns, common mistakes, and reference implementations. |
| updated | 2026-07-30T00:00:00.000Z |
Vue to Rust (WASM) Migration
Note: This skill covers frontend/WASM migration, which is fundamentally different from the backend/runtime language migrations in the other *-to-rust skills. While other skills map server-side runtimes (Python, Go, Java, etc.) to Rust native binaries, this skill maps a browser-based frontend framework to Rust compiled to WebAssembly. The target runtimes are Leptos and Dioxus, which run in the browser via WASM, not as native binaries.
Architecture Mapping
Vue's reactive component model (Vue SFC with <template>/<script>/<style>, reactivity via ref()/reactive(), virtual DOM diffing) maps to Rust WASM frameworks (Leptos, Dioxus, Yew) that compile to WebAssembly and run in the browser at near-native speed. Where Vue relies on JavaScript's runtime type system and V8's JIT compilation, Rust WASM frameworks use AOT-compiled Rust with fine-grained reactivity (signals) and zero-cost iterator-based rendering โ no virtual DOM overhead in Leptos/Dioxus.
The migration is fundamentally a shift from JavaScript's runtime dynamism to Rust's compile-time guarantees, but within the same browser execution environment. Vue's single-file components become Rust component functions; Vue's ref()/computed() become RwSignal/Memo; Vue Router becomes leptos_router; Pinia/Vuex stores become signal-based reactive stores. The build pipeline shifts from Vite/webpack to Trunk/wasm-pack, and npm dependencies become Cargo crates. For incremental adoption, Rust WASM modules can be imported into an existing Vue app via wasm-bindgen, allowing component-by-component replacement. For SSR (Nuxt), Leptos and Dioxus Fullstack provide isomorphic Rust rendering on both server and client.
| Vue Concept | Rust Equivalent |
|---|
| Vue SFC (.vue) | Leptos component function / Yew functional component / Dioxus rsx! |
| Vue instance / app | leptos::mount_to_body() / yew::Renderer::<App>::new().render() |
| Vue CLI / Vite | Trunk / wasm-pack + webpack plugin / Cargo |
| npm / yarn / pnpm | Cargo + crates.io |
| Vue DevTools | console_error_panic_hook / browser dev console |
vue-router | leptos_router / yew-router / dioxus-router |
| Pinia / Vuex | leptos::RwSignal store module / reactive_stores macro |
vue-i18n | fluent-rs / rust-i18n / custom compile-time i18n |
| TypeScript | Rust (always typed; no distinction) |
| Nuxt (SSR) | Leptos SSR / Yew Server-Side Rendering / Dioxus Fullstack |
The migration target is WebAssembly, which means: no direct DOM access (uses framework abstracted DOM), no blocking the main thread (use wasm-bindgen-futures), and a compile-step between Rust source and browser JavaScript.
Type System Mapping
Vue uses JavaScript/TypeScript types. Rust WASM code communicates with JavaScript via wasm-bindgen.
| Vue / JS / TS Type | Rust Type | Notes |
|---|
string | String | Direct mapping via wasm-bindgen |
number | f64 / i32 / u32 | Choose based on domain; wasm-bindgen bridges to JS number |
boolean | bool | Direct mapping |
null / undefined | Option<T> | JsValue::NULL / JsValue::UNDEFINED when bridging to JS |
Array<T> | Vec<T> / js_sys::Array | Vec is copied across boundary; Array for large data |
Object / Record<K,V> | HashMap<K,V> / BTreeMap<K,V> / struct | For JS interop, JsValue with Reflect::get |
Map<K,V> | js_sys::Map / HashMap<K,V> | Native Map via js-sys when needed |
Set<T> | js_sys::Set / HashSet<T> | |
Promise<T> | wasm_bindgen_futures::JsFuture | .await a JS Promise from Rust |
Function | js_sys::Function | Call JS callbacks via wasm-bindgen |
Ref<T> (template ref) | NodeRef (Leptos) / NodeRef (Yew) | |
Event | web_sys::Event / web_sys::MouseEvent | Typed event wrappers via web-sys |
| Template literal type |
Prop Validation in Rust
#[component]
fn MyComponent(
#[prop(into)] title: String,
#[prop(optional)] count: Option<i32>,
#[prop(default = 0)] count_default: i32,
) -> impl IntoView {
view! { <div>{title} โ count: {count_default}</div> }
}
Memory & Ownership Model
Vue's reactivity system automatically tracks dependencies and cleans up. Rust WASM frameworks use signals and explicit ownership for similar behavior.
| Vue Reactivity | Rust Equivalent |
|---|
ref() / reactive() | create_signal() / RwSignal::new() (Leptos) |
computed() | create_memo() / Memo::new() (Leptos) |
watch() | create_effect() (Leptos) / use_effect() (Dioxus) |
watchEffect() | create_effect() โ runs immediately and on every dependency change |
shallowRef | ArcSwap<T> / signal with manual .set() |
toRaw() / markRaw() | Not needed โ visibility controlled by pub |
readonly() | ReadSignal / Signal::read_only() |
$ref() (Vue 3.5) | Signal getter/setter pattern |
| Automatic cleanup on unmount | on_cleanup() / Drop implementation |
provide / inject | provide_context() / use_context() (Leptos) |
Reactive Store Translation (Pinia/Vuex)
use leptos::prelude::*;
#[derive(Clone)]
pub struct CounterStore {
pub count: RwSignal<i32>,
pub double: Memo<i32>,
}
impl CounterStore {
pub fn new() -> Self {
let count = RwSignal::new(0);
let double = Memo::new(move |_| count.get() * 2);
Self { count, double }
}
pub fn increment(&self) {
self.count.update(|n| *n += 1);
}
}
Concurrency / Async Translation
Vue runs on the browser's single-threaded event loop. Rust WASM uses wasm-bindgen-futures to bridge the JS Promise world with Rust's async/await.
| Vue / JS Async | Rust WASM Equivalent |
|---|
async/await | async fn / .await (requires wasm-bindgen-futures executor) |
Promise.all([a, b]) | futures::future::join_all / join!(a, b) |
Promise.race([a, b]) | futures::future::select(a, b) |
setTimeout(fn, ms) | gloo_timers::future::TimeoutFuture::new(ms) |
setInterval(fn, ms) | gloo_timers::callback::Interval |
fetch() / axios | reqwest (WASM feature) / gloo_net::http::Request |
WebSocket | web_sys::WebSocket / gloo_net::websocket |
requestAnimationFrame(fn) | request_animation_frame() via wasm-bindgen |
EventTarget.addEventListener | web_sys::EventTarget::add_event_listener_with_callback |
| Worker thread | web_sys::Worker / wasm_bindgen_rayon |
AbortController | futures::future::AbortHandle / Drop the future |
Async Data Fetching Component
use leptos::prelude::*;
use serde::{Deserialize, Serialize};
#[derive(Clone, Debug, Serialize, Deserialize)]
struct User {
id: u64,
name: String,
email: String,
}
#[component]
fn UserProfile(user_id: u64) -> impl IntoView {
let user = LocalResource::new(
move || user_id,
|id| async move {
let url = format!("/api/user/{id}");
reqwest::get(&url)
.await
.ok()
.and_then(|r| r.json::<User>().await.ok())
},
);
view! {
<Suspense fallback=|| view! { <p>"Loading..."</p> }>
{move || Suspend::new(async move {
user.await.map(|u| view! {
<h2>{u.name.clone()}</h2>
<p>{u.email.clone()}</p>
})
})}
</Suspense>
}
}
Build System & Dependencies
| Vue Tool | Rust Equivalent |
|---|
npm create vue@latest | cargo init + add leptos/yew/dioxus dependency |
vite.config.ts | Trunk.toml / Cargo.toml [profile.release] |
package.json | Cargo.toml |
node_modules/ | target/ (compilation artifacts) |
npm run dev | trunk serve / dx serve (Dioxus) |
npm run build | trunk build --release / wasm-pack build |
npm run test | wasm-pack test --headless / cargo test |
tsconfig.json | Cargo.toml + rust-toolchain.toml |
.env / import.meta.env | dotenvy + compile-time env with env!() |
| PostCSS / Tailwind | CSS file linked in index.html or stylist-rs |
| ESLint / Prettier | clippy / rustfmt |
| Husky (git hooks) | cargo-husky / manual git hooks |
Sample Cargo.toml for a Vue-to-Rust Migration
[package]
name = "my-app"
version = "0.1.0"
edition = "2021"
[dependencies]
leptos = { version = "0.7", features = ["csr"] }
leptos_router = { version = "0.7", features = ["csr"] }
wasm-bindgen = "0.2"
wasm-bindgen-futures = "0.4"
web-sys = { version = "0.3", features = ["Window", "Document", "Element", "HtmlElement"] }
serde = { version = "1", features = ["derive"] }
serde_json = "1"
reqwest = { version = "0.12", features = ["json"] }
gloo-net = "0.6"
gloo-timers = { version = "0.3", features = ["futures"] }
console_error_panic_hook = "0.1"
tracing = "0.1"
tracing-wasm = "0.2"
[profile.release]
opt-level = "s"
lto = true
codegen-units = 1
Standard Library & Ecosystem Mapping
| Vue / JS Library | Rust Equivalent |
|---|
vue-router | leptos_router / yew-router / dioxus-router |
| Pinia / Vuex | leptos::RwSignal / reactive stores |
@vueuse/core (useMouse, useStorage) | gloo-events / web-sys + signal |
axios / fetch() | reqwest (WASM) / gloo-net::http |
lodash | itertools + std collections |
dayjs / date-fns | chrono (with wasm-bindgen feature) / time |
vee-validate / zod | validator crate / garde |
vue-i18n | fluent-rs / rust-i18n / custom compile-time phf map |
marked (markdown) | pulldown-cmark (compile-time safe) |
| hammer.js (gestures) | web-sys TouchEvent / gloo-events |
echarts / chart.js | plotters (renders to WASM canvas) / d3-alike via web-sys |
highlight.js | syntect (server side) / tree-sitter highlighting |
socket.io-client | gloo-net::websocket / web_sys::WebSocket |
localStorage | web_sys::Storage / gloo-storage |
IndexedDB | web_sys::IdbDatabase / crate |
Canonical Patterns
Pattern 1: v-if / v-show to Conditional Rendering
use leptos::prelude::*;
#[component]
fn Conditional() -> impl IntoView {
let (is_visible, set_visible) = signal(true);
view! {
<Show when=move || is_visible.get()
fallback=|| view! { <p>"Hidden with v-if"</p> }
>
<p>"Shown"</p>
</Show>
<p style:display=move || if is_visible.get() { "block" } else { "none" }>
"Shown with v-show style"
</p>
}
}
Pattern 2: v-for to Iterator Map
use leptos::prelude::*;
#[derive(Clone, Debug, PartialEq, Eq)]
struct ListItem { id: u64, name: String }
#[component]
fn ItemList(items: Vec<ListItem>) -> impl IntoView {
view! {
<ul>
{items.into_iter().map(|item| view! {
<li key={item.id}>{item.name.clone()}</li>
}).collect_view()}
</ul>
}
}
#[component]
fn DynamicList() -> impl IntoView {
let (items, set_items) = signal::<Vec<ListItem>>(vec![]);
view! {
<For
each=move || items.get()
key=|item: &ListItem| item.id
children=|item: ListItem| view! { <li>{item.name}</li> }
/>
}
}
Pattern 3: v-model Two-Way Binding
use leptos::{prelude::*, html::Input};
#[component]
fn TextInput() -> impl IntoView {
let (value, set_value) = signal(String::new());
view! {
<input type="text"
prop:value=move || value.get()
on:input=move |ev| {
set_value.set(event_target_value(&ev));
}
/>
<p>"You typed: " {move || value.get()}</p>
}
}
Pattern 4: Component Emits as Callback Props
#[component]
fn Child<F>(on_change: F) -> impl IntoView
where
F: Fn(String) + 'static,
{
view! {
<button on:click=move |_| on_change("new value".into())>
"Trigger"
</button>
}
}
#[component]
fn Parent() -> impl IntoView {
let (msg, set_msg) = signal(String::new());
view! {
<Child on_change=move |v| set_msg.set(v) />
<p>{move || msg.get()}</p>
}
}
Pattern 5: Lifecycle Hooks to on_cleanup / on_mount
use leptos::prelude::*;
#[component]
fn Lifecycled() -> impl IntoView {
on_mount(move || {
});
on_cleanup(|| {
});
view! { <div>"Component"</div> }
}
Pattern 6: Vue Transition to Leptos AnimatedShow
use leptos::prelude::*;
#[component]
fn Animated() -> impl IntoView {
let (show, set_show) = signal(true);
view! {
<AnimatedShow
when=move || show.get()
show_class="fade-enter-active"
hide_class="fade-leave-active"
>
<p>"Hello"</p>
</AnimatedShow>
}
}
Pattern 7: Scoped CSS
use stylist::yew::styled_component;
use yew::prelude::*;
#[styled_component]
fn MyComponent() -> Html {
html! {
<div class={css!(r#"
.title { color: red; }
"#)}>
<h1 class="title">{"Hello"}</h1>
</div>
}
}
FFI & Incremental Migration
When migrating a Vue app, you can run Vue and Rust WASM side by side in the same page.
| Strategy | Tool | When to Use |
|---|
| WASM module in Vue app | wasm-pack build --target bundler | Incrementally replace Vue components |
| Vue renders Rust-WASM component | Custom element via wasm-bindgen | Wrap Rust component as <my-wasm-component> |
| Rust app calls legacy JS | js_sys / web_sys / wasm-bindgen imports | Leverage existing JS libraries |
| Micro-frontend approach | Separate entry points, shared router | Large app with isolated Rust modules |
| Full rewrite | Pure Rust WASM framework | Small-to-medium apps |
Calling Rust WASM from Vue
use wasm_bindgen::prelude::*;
#[wasm_bindgen]
pub struct Calculator { value: f64 }
#[wasm_bindgen]
impl Calculator {
pub fn new() -> Self { Self { value: 0.0 } }
pub fn add(&mut self, n: f64) -> f64 {
self.value += n;
self.value
}
pub fn get_value(&self) -> f64 { self.value }
}
import init, { Calculator } from '../wasm-pkg/my_app';
const calc = shallowRef<Calculator | null>(null);
onMounted(async () => {
await init();
calc.value = Calculator.new();
});
function add(n: number) {
calc.value?.add(n);
}
Migration Order
- Move computation-heavy functions to Rust WASM first (sorting, filtering, string processing).
- Extract state management from Pinia/Vuex into
wasm-bindgen-exposed Rust structs.
- Replace leaf components (buttons, inputs, cards) with WASM-rendered equivalents.
- Rewrite page-level components using a Rust framework.
- Switch routing to Rust-side routing with history API.
- Remove Vue dependency entirely.
Common Mistakes
Mistake 1: Blocking the Main Thread with Heavy Computation
fn heavy_processing(input: &[u8]) -> Vec<u8> {
input.iter().map(|b| b.wrapping_mul(3)).collect()
}
async fn heavy_processing_non_blocking(input: Vec<u8>) -> Vec<u8> {
let chunk_size = 1000;
let mut result = Vec::with_capacity(input.len());
for chunk in input.chunks(chunk_size) {
result.extend(chunk.iter().map(|b| b.wrapping_mul(3)));
gloo_timers::future::TimeoutFuture::new(0).await;
}
result
}
Mistake 2: Passing Large Objects Across WASM Boundary Frequently
#[wasm_bindgen]
pub fn render_table(data: Vec<Row>) -> Vec<u8> { }
#[wasm_bindgen]
pub struct TableEngine { rows: Vec<Row>, render_cache: Vec<u8> }
#[wasm_bindgen]
impl TableEngine {
pub fn update_row(&mut self, idx: usize, row: Row) {
self.rows[idx] = row;
}
}
Mistake 3: Cloning Large Structures in Signal Updates
let (items, set_items) = signal(vec![1i32; 10000]);
set_items.update(|v| {
let mut new_vec = v.clone();
new_vec.push(42);
new_vec
});
set_items.update(|v| v.push(42));
Mistake 4: Forgetting to Register the Panic Hook
fn main() {
mount_to_body(|| view! { <App /> });
}
fn main() {
console_error_panic_hook::set_once();
tracing_wasm::set_as_global_default();
mount_to_body(|| view! { <App /> });
}
Mistake 5: Using std::time::Instant::now() in WASM
let start = std::time::Instant::now();
fn now_ms() -> f64 {
web_sys::window()
.and_then(|w| w.performance())
.map(|p| p.now())
.unwrap_or(0.0)
}
Reference Implementations
| Project | Description | Migration Pattern |
|---|
| Leptos Hacker News Example | Complete SPA in Leptos | Full Rust WASM rewrite pattern |
| Yew Realworld Example | Medium clone in Yew | Full SPA migration from Vue/React |
| Dioxus Tailwind | Tailwind CSS in Dioxus | Build system equivalent to Vite + PostCSS |
| r3bl-view | TUI and GUI framework in Rust | Component architecture patterns |
| Perseus | SSR framework with state management | Nuxt-to-Rust equivalent |
| sycamore | Fine-grained reactivity, no VDOM | Vue 3 reactivity model analogue |
Cross-Reference
- nodejs-to-rust โ For migrating Express/Fastify backends and npm build tooling
- go-to-rust โ For migrating API services that Vue frontends connect to
- php-to-rust โ For migrating Laravel backends paired with Vue frontends
- java-to-rust โ For migrating Spring Boot backends paired with Vue frontends