Migrating from Vue.js to rfw
You know Vue, reactive data, SFCs, computed properties, v-model, slots, Vuex/Pinia. rfw borrows many of the same ideas but implements them in Go, compiled to WebAssembly, with type safety and no JavaScript runtime.
This guide maps Vue concepts to rfw so you can get productive quickly.
Why Go + WASM?
Vue runs in JavaScript. rfw runs in Go, compiles to WASM, and ships no JS framework bundle. Benefits:
- Single language, server and client are both Go. Share types, validators, domain logic.
- Type safety, the compiler catches mismatched signals, missing handlers, wrong prop types at build time, not at runtime.
- SSC out of the box, Server-Side Computed rendering with WebSocket hydration is required and built-in. No Nuxt configuration needed.
- Fine-grained reactivity, rfw patches only the DOM nodes that depend on changed signals, no virtual DOM diffing.
Trade-offs: rfw’s ecosystem is smaller, the WASM initial load is larger than a minimal Vue bundle, and you lose access to npm packages on the client side.
Mindset Shift
Vue: Proxy-based reactivity
const state = reactive({ count: 0 })
Vue wraps objects in JavaScript Proxies. Any property access or mutation is intercepted automatically. The system is flexible but relies on runtime tracking.
rfw: Explicit signals with type-based detection
type Counter struct {
composition.Component
Count *t.Int
}
Signals are explicit. You declare them as typed fields (*t.Int, *t.String, etc.), and composition.New wires them automatically based on their type. There are no hidden proxies, every reactive field is visible in the struct.
Reading and writing are explicit:
c.Count.Get() // read
c.Count.Set(5) // write
This is more verbose than state.count = 5, but every reactive access is traceable at compile time.
Component Model
Vue SFC
<template>
<p>{{ count }}</p>
<button @click="increment">+1</button>
</template>
<script setup>
import { ref } from 'vue'
const count = ref(0)
const increment = () => count.value++
</script>
<style scoped>
p { color: blue; }
</style>
rfw struct + RTML template
//go:build js && wasm
package components
import (
"github.com/rfwlab/rfw/v2/composition"
t "github.com/rfwlab/rfw/v2/types"
)
type Counter struct {
composition.Component
Count *t.Int
}
func (c *Counter) Increment() {
c.Count.Set(c.Count.Get() + 1)
}
Counter.rtml (auto-discovered from registered embed.FS):
<root>
<p>@signal:Count</p>
<button @on:click:Increment>+1</button>
</root>
Key differences:
- The struct is the component. No
<script setup>, nodefineProps, noemit.
- The template is a separate
.rtmlfile, found by convention (struct name → filename).
- State lives on the struct as signal fields. No
ref()orreactive().
- Methods on the struct are handlers. No
@click="handler"string→function lookup ambiguity.
Template Syntax Comparison
v-if → @if:
Vue:
<p v-if="count > 0">Positive</p>
<p v-else-if="count === 0">Zero</p>
<p v-else>Negative</p>
rfw:
@if:Count.Get > 0
<p>Positive</p>
@else-if:Count.Get == 0
<p>Zero</p>
@else
<p>Negative</p>
@endif
rfw uses @if:/@else-if:/@else/@endif blocks instead of directives on elements. Expressions reference Go struct fields or signal .Get().
v-for → @for:
Vue:
<li v-for="item in items" :key="item.id">{{ item.text }}</li>
rfw:
@for:item in items
<li [key {{item.ID}}]>{{item.Text}}</li>
@endfor
- No
v-foron the element itself,@foris a block directive.
- Keys use
[key {{expr}}]constructors, not:key.
- Range syntax:
@for:i in 0..N.Get.
v-model → @signal:...:w
Vue:
<input v-model="name" />
<textarea v-model="bio"></textarea>
<input type="checkbox" v-model="done" />
rfw:
<input value="@signal:Name:w">
<textarea>@signal:Bio:w</textarea>
<input type="checkbox" checked="@signal:Done:w">
Append :w for two-way binding. Without :w, the binding is read-only.
:class and computed bindings → @expr:
Vue:
<div :class="{ active: isActive, 'text-bold': isBold }">
{{ fullName }}
</div>
Vue computed:
const fullName = computed(() => firstName.value + ' ' + lastName.value)
rfw:
<div class="@expr:isActive && 'active' @expr:isBold && 'text-bold'">
@expr:FirstName.Get + ' ' + LastName.Get
</div>
For complex logic, use a Go method:
func (u *UserProfile) FullName() string {
return u.FirstName.Get() + " " + u.LastName.Get()
}
<p>{{FullName}}</p>
v-on:click → @on:click:
Vue:
<button @click="increment">+1</button>
<form @submit.prevent="save">...</form>
rfw:
<button @on:click:Increment>+1</button>
<form @on:submit.prevent:Save>...</form>
Event modifiers map directly:
| Vue | rfw |
|---|---|
.prevent |
.prevent |
.stop |
.stop |
.once |
.once |
computed → @expr: or Go methods
Vue:
const doubled = computed(() => count.value * 2)
rfw, inline:
<p>Doubled: @expr:Count.Get * 2</p>
rfw, Go method:
func (c *Counter) Doubled() int {
return c.Count.Get() * 2
}
<p>Doubled: {{Doubled}}</p>
props → t.Prop[T] or signal fields
Vue:
defineProps({ title: String, count: Number })
rfw, via t.Prop[T] fields:
type Card struct {
composition.Component
Title t.Prop[string]
Count t.Prop[int]
}
Or via signal fields passed at construction:
type Card struct {
composition.Component
Title *t.String
Count *t.Int
}
Parent passes props when creating the component:
card, err := composition.New(&Card{
Title: t.NewString("Hello"),
Count: t.NewInt(0),
})
if err != nil {
log.Fatal(err)
}
For cross-component prop flow, use t.Prop[T] fields or *t.View for layout composition (see below).
emit → handler methods
Vue:
const emit = defineEmits(['update', 'delete'])
emit('update', newValue)
rfw: Components don’t emit events. Instead, pass callbacks via dependency injection or call parent methods directly:
parent.On("childUpdate", func() { /* ... */ })
Or use stores to communicate between components without direct coupling.
Slots → *t.View field / @include:
Vue parent:
<Layout>
<template #content>Page content here</template>
</Layout>
Vue Layout:
<slot name="content">Default content</slot>
rfw, Layout struct:
type Layout struct {
composition.Component
Content *t.View
}
The slot name is derived from the lowercase field name (Content → content).
Layout.rtml:
<root>
<nav>My App</nav>
<main>@include:content</main>
</root>
rfw, using the layout:
layout, err := composition.New(&Layout{})
if err != nil {
log.Fatal(err)
}
// Wire a child into the "content" slot
layout.AddDependency("content", pageView)
State Management
Vuex / Pinia → rfw stores
Pinia:
export const useCounterStore = defineStore('counter', {
state: () => ({ count: 0, name: 'rfw' }),
getters: {
doubled: (state) => state.count * 2,
},
actions: {
increment() { this.count++ },
},
})
rfw, Store:
import "github.com/rfwlab/rfw/v2/state"
s := state.NewStore("counter", state.WithModule("app"))
s.Set("count", 0)
s.Set("name", "rfw")
// Computed
state.Map(s, "doubled", "count", func(v int) int { return v * 2 })
// Actions are just functions that call Set
func increment(s *state.Store) {
c := s.Get("count").(int)
s.Set("count", c+1)
}
Template access:
<p>Count: @store:app.counter.count</p>
<p>Doubled: @store:app.counter.doubled</p>
<input value="@store:app.counter.name:w">
Local state: ref / reactive → signals
Vue:
const count = ref(0)
const user = reactive({ name: '', age: 0 })
rfw:
type MyComp struct {
composition.Component
Count *t.Int
Name *t.String
Age *t.Int
}
Signals are always single values. There is no equivalent to Vue’s reactive() for plain objects, use a Store for multi-key state, or separate signal fields.
Routing
Vue Router → router.Page() / router.Group()
Vue:
const routes = [
{ path: '/', component: Home },
{ path: '/about', component: About },
{ path: '/users/:id', component: UserProfile },
]
const router = createRouter({ routes, history: createWebHistory() })
rfw:
func main() {
router.Page("/", func() *composition.View {
v, _ := composition.New(&Home{})
return v
})
router.Page("/about", func() *composition.View {
v, _ := composition.New(&About{})
return v
})
router.Page("/users/:id", func() *composition.View {
v, _ := composition.New(&UserProfile{})
return v
})
router.Group("/admin", func(r *router.GroupBuilder) {
r.Page("/dashboard", func() *composition.View {
v, _ := composition.New(&Dashboard{})
return v
})
})
router.InitRouter()
select {}
}
Route params
Vue:
const route = useRoute()
console.log(route.params.id)
rfw:
func (u *UserProfile) OnMount() {
params := u.HTMLComponent.RouteParams()
id := params["id"]
}
Navigation guards
Vue:
router.beforeEach((to, from, next) => {
if (!isAuthenticated()) next('/login')
else next()
})
rfw:
func requireAuth(params map[string]string) bool {
return session.IsAuthenticated()
}
router.Page("/dashboard", dashboardView, requireAuth)
Guards are per-route functions. If any returns false, navigation is blocked.
Lifecycle Hooks
| Vue | rfw | Notes |
|---|---|---|
onMounted() |
func (c *T) OnMount() |
Auto-discovered by composition.New |
onUnmounted() |
func (c *T) OnUnmount() |
Auto-discovered |
onUpdated() |
(signals auto-update DOM) | No explicit hook needed |
watch() |
state.Effect() or store OnChange |
See Signals & Effects |
watchEffect() |
state.Effect() |
Auto-tracks signal dependencies |
Vue:
onMounted(() => {
console.log('mounted')
const timer = setInterval(() => count.value++, 1000)
onUnmounted(() => clearInterval(timer))
})
rfw:
type Timer struct {
composition.Component
Count *t.Int
done chan struct{}
}
func (t *Timer) OnMount() {
ticker := time.NewTicker(time.Second)
go func() {
for {
select {
case <-ticker.C:
t.Count.Set(t.Count.Get() + 1)
case <-t.done:
ticker.Stop()
return
}
}
}()
}
func (t *Timer) OnUnmount() {
close(t.done)
}
SSR: Nuxt → rfw SSC
SSC is required in rfw v2. There is no SPA fallback mode.
Nuxt (Vue):
- Configure SSR in
nuxt.config.ts
- Server renders HTML on each request
- Client hydrates with Vue runtime
- Client-side navigation takes over
rfw SSC:
rfw buildproduces a WASM client bundle and a host binary
- Host renders HTML and sends it to the browser
- Browser loads WASM, hydrates the rendered HTML
- A persistent WebSocket keeps client and server state synchronized
h:bindings and commands carry server-side data
// Host (server side)
host.Register(host.NewHostComponent("GreetingHost", func(_ map[string]any) any {
return map[string]any{"hostMsg": "hello from server"}
}))
sscSrv := ssc.NewSSCServer(":8080", "client")
sscSrv.ListenAndServe()
<root>
<p>Host: {h:hostMsg}</p>
<button @on:click:h:updateTime>refresh</button>
</root>
Key differences from Nuxt:
- No
asyncData,fetch, oruseAsynchooks, the host component provides data directly.
- No client-side routing fallback. SSC is mandatory.
- Server-side code is Go, not JavaScript. You share types and business logic across server and client.
Common Patterns Side-by-Side
Todo List
Vue:
<template>
<input v-model="newTodo" @keyup.enter="addTodo" />
<ul>
<li v-for="todo in todos" :key="todo.id">
{{ todo.text }}
<button @click="removeTodo(todo.id)">×</button>
</li>
</ul>
</template>
<script setup>
import { ref } from 'vue'
const newTodo = ref('')
const todos = ref([])
const addTodo = () => {
todos.value.push({ id: Date.now(), text: newTodo.value })
newTodo.value = ''
}
const removeTodo = (id) => {
todos.value = todos.value.filter(t => t.id !== id)
}
</script>
rfw:
type TodoApp struct {
composition.Component
NewTodo *t.String
Todos *t.Any
}
func (a *TodoApp) AddTodo() {
todos := a.Todos.Get().([]TodoItem)
todos = append(todos, TodoItem{ID: len(todos), Text: a.NewTodo.Get()})
a.Todos.Set(todos)
a.NewTodo.Set("")
}
func (a *TodoApp) OnMount() {
a.Todos.Set([]TodoItem{})
}
<root>
<input value="@signal:NewTodo:w" @on:keyup.enter:AddTodo>
<ul>
@for:todo in Todos
<li [key {{todo.ID}}]>
{{todo.Text}}
<button @on:click:RemoveTodo-{{todo.ID}}>×</button>
</li>
@endfor>
</ul>
</root>
Form with Validation
Vue:
<template>
<form @submit.prevent="submit">
<input v-model="email" />
<span v-if="!valid">{{ error }}</span>
<button type="submit">Submit</button>
</form>
</template>
<script setup>
import { ref, computed } from 'vue'
const email = ref('')
const valid = computed(() => email.value.includes('@'))
const error = computed(() => valid.value ? '' : 'Invalid email')
const submit = () => { if (valid.value) { /* ... */ } }
</script>
rfw:
type Form struct {
composition.Component
Email *t.String
}
func (f *Form) Valid() bool {
return strings.Contains(f.Email.Get(), "@")
}
func (f *Form) Error() string {
if f.Valid() {
return ""
}
return "Invalid email"
}
func (f *Form) Submit() {
if f.Valid() {
// submit
}
}
<root>
<form @on:submit.prevent:Submit>
<input value="@signal:Email:w">
@if:!Valid
<span>@expr:Error</span>
@endif
<button type="submit">Submit</button>
</form>
</root>
Quick Reference: Vue → rfw
| Vue | rfw | Notes |
|---|---|---|
ref() |
*t.Int / *t.String etc (type-detected) |
Typed signals |
reactive() |
*t.Store or multiple signals |
No proxy-based objects |
computed() |
Go method or @expr: |
Methods auto-invoked in templates |
v-model |
@signal:Name:w |
Two-way via :w suffix |
v-if / v-else-if / v-else |
@if: / @else-if: / @else / @endif |
Block directives |
v-for |
@for:item in items / @endfor |
Block directive |
@click / v-on:click |
@on:click:Handler |
Handler is struct method |
:class |
@expr:condition && 'class' |
Computed expression |
:style |
style="{{expr}}" |
Expression in attribute |
defineProps() |
t.Prop[T] fields or signal fields |
Auto-wired by composition.New |
defineEmits() |
Handler methods / stores | No formal emit system |
<slot name="x"> |
@slot:x / @endslot |
Slot in layout template |
<Comp #x="child"> |
@include:Comp + *t.View field |
Slot name from field name |
onMounted() |
func (c *T) OnMount() |
Auto-discovered |
onUnmounted() |
func (c *T) OnUnmount() |
Auto-discovered |
watch() |
state.Effect() or store.OnChange |
|
provide/inject |
*t.Inject[T] or Provide/Inject |
DI container or component tree |
Pinia store |
state.NewStore() / *t.Store |
Namespaced key-value |
vue-router |
router.Page() / router.Group() |
|
beforeEach guard |
func(map[string]string) bool |
Per-route guards |
Nuxt SSR |
rfw SSC (required) | Server renders, WASM hydrates |
.vue SFC |
.go struct + .rtml template |
Two files per component |
<script setup> |
Go struct definition | No separate setup function |
<style scoped> |
External CSS / Tailwind | No built-in scoped CSS |