Architecture
rfw v2 builds reactive web UIs entirely in Go. The runtime compiles to WebAssembly for the browser, while templates are written in RTML, an HTML-like language that binds directly to Go state. Components are struct-driven, type-wired, and convention-resolved. No manual glue code.
Composition-Based Design
v2 replaces manual component wiring with composition.New(&MyStruct{}). You define a Go struct with typed fields, and the framework scans, resolves templates, wires signals/stores/events/injects/lifecycle — all automatically.
type Counter struct {
composition.Component
Count t.Int
}
func (c *Counter) Inc() { c.Count.Set(c.Count.Get() + 1) }
view, err := composition.New(&Counter{Count: *t.NewInt(0)})
No dom.RegisterHandlerFunc, no c.Props["count"] map lookups, no manual template loading. Field types determine wiring; convention does the rest.
How composition.New Works
composition.New(&struct{}) executes these steps in order:
- Validate, must be a pointer to a struct.
- Scan field types, the
scanpackage inspects field types (not tags) to detect signals, stores, refs, injects, histories, host types, and includes.
- Resolve template, checks for a
Template() stringmethod first, then convention (struct name →StructName.rtmlorStructName.html). Returns error if neither found.
- Create component, calls
core.NewHTMLComponent(name, template, nil).
- Initialize default store, creates or reuses the
"app"/"default"store.
- Wire signals, for each signal-type field: if nil pointer, creates a zero-value signal and sets the field; registers as prop.
- Wire host signals,
t.HInt,t.HString, etc. are registered as both props and host component bindings.
- Wire stores,
*t.Storefields get the store from the global manager.
- Wire histories,
*t.Historyfields are bound to the component’s first store.
- Wire includes,
*t.Viewfields callAddDependency(lowercase field name, view).
- Wire injects,
*t.Inject[T]fields are resolved from the DI container.
- Wire refs,
*t.Reffields are allocated and resolved from the DOM on mount.
- Auto-discover methods, exported zero-arg no-return methods are auto-registered as event handlers (excluding
OnMount/OnUnmountand Component methods).
- Wire lifecycle,
OnMountandOnUnmountare registered; refs are resolved beforeOnMount.
Returns (*View, error). Returns a descriptive error instead of panicking on failure.
Template Resolution
Templates are resolved in this order:
- Template() method, if the struct implements
Template() string, that string is used directly.
- Convention, the struct name becomes the template name.
HomePage→ searches all registered FS forHomePage.rtmlorHomePage.html(root level first, then subdirectories).
- Error, if neither found, returns an error.
Register your template FS in init():
//go:embed pages/templates components/templates
var templates embed.FS
func init() {
composition.RegisterFS(&templates)
}
Router
The v2 router uses Page() and Group() for route definition:
router.Page("/", func() *t.View {
view, _ := composition.New(&components.Layout{
Content: pages.NewHomePage(),
})
return view
})
router.Group("/admin", func(g *router.GroupBuilder) {
g.Page("/dashboard", func() *t.View {
v, _ := composition.New(&admin.Dashboard{})
return v
})
g.Page("/settings", func() *t.View {
v, _ := composition.New(&admin.Settings{})
return v
})
})
router.InitRouter()
Page(path, component), register a single route.
Group(prefix, fn), nest routes under a prefix with aGroupBuilder.
InitRouter(), starts listening forpopstateevents and navigates to the current URL.
- Routes support path params (
/user/:id), guards, and singletons viarouter.Singleton(view).
Reactivity
Signals, Local Reactive State
Signals are fine-grained reactive values. Use t.Int, t.String, t.Bool, t.Float, or t.Any:
count := t.NewInt(0)
count.Set(42)
fmt.Println(count.Get()) // 42
Auto-wired by type detection:
type Counter struct {
composition.Component
Count t.Int // value type — auto-wired
Name *t.String // pointer type — auto-initialized if nil
}
If Name is nil at construction, composition.New creates a zero-value signal automatically.
In templates:
<span>@signal:Count</span>
<input value="@signal:Name:w">
@signal:Name, read-only binding
@signal:Name:w, two-way binding (writes back on input)
Stores, Global State
Stores are namespaced key-value maps shared across components:
s := state.NewStore("cart", state.WithModule("app"), state.WithHistory(10))
s.Set("count", 0)
s.Set("name", "rfw")
Auto-wired by type:
type CartPage struct {
composition.Component
Cart *t.Store
}
In templates:
<p>Items: @store:app.cart.count</p>
<input value="@store:app.cart.name:w">
Format: @store:module.store.key, module defaults to app, so @store:app.default.count is the default store.
Computed Values, @expr:
Inline computed expressions re-evaluate when dependencies change:
<p>Double: @expr:Count.Get * 2</p>
Or define computed stores in Go:
state.Map2(s, "fullName", "first", "last", func(first, last string) string {
return first + " " + last
})
RTML Template Directives
RTML connects Go state to the DOM through directives:
| Directive | Purpose | Example |
|---|---|---|
@signal:Name |
Read a signal | @signal:Count |
@signal:Name:w |
Two-way signal binding | value="@signal:Name:w" |
@store:m.s.k |
Read a store key | @store:app.cart.total |
@store:m.s.k:w |
Two-way store binding | value="@store:app.cart.name:w" |
@expr: |
Computed expression | @expr:Count.Get * 2 |
@on:click:handler |
DOM event → Go method | @on:click:Increment |
@include:slot |
Inject child view | @include:content |
@if:cond / @endif |
Conditional rendering | @if:signal:Count == "3" |
@for:item in signal:Items / @endfor |
List rendering | Loop over signals |
Host/Client SSC Split
SSC (Server-Side Computed) is required in v2. The architecture splits into two processes:
- Host, Go server that renders HTML, runs privileged logic, and serves the Wasm bundle.
- Client, Wasm bundle that hydrates server HTML, handles local reactivity, and syncs with the host over WebSocket.
Client components declare host bindings with host signal types (t.HInt, t.HString, etc.):
type Greeting struct {
composition.Component
ClientMsg t.String
Visit t.HInt
}
In templates, host values use h: prefix:
<p>Client: @signal:ClientMsg</p>
<p>Host: {h:visit}</p>
<button @on:click:h:updateTime>Refresh</button>
The host registers a matching component:
host.Register(host.NewHostComponent("Visit", func(_ map[string]any) any {
return map[string]any{"visit": 0}
}))
The SSC server serves static files and handles WebSocket connections:
sscSrv := ssc.NewSSCServer(":8080", "client")
sscSrv.ListenAndServe()
Build Pipeline
rfw v2 compiles your Go code to WebAssembly and embeds templates via embed.FS:
- Go → WASM,
GOOS=js GOARCH=wasm go build -o app.wasmfor the client.
- Host binary, standard Go build for the server.
- Template embedding, use
//go:embeddirectives andcomposition.RegisterFS(&fs)ininit().
- rfw CLI,
rfw buildproduces bothbuild/client/(Wasm + assets) andbuild/host/(server binary).rfw devwatches and rebuilds on changes.
Project Structure
myapp/
├── main.go // client entry, router, RegisterFS
├── components/
│ ├── layout.go
│ └── templates/
│ └── Layout.rtml
├── pages/
│ ├── home.go
│ └── templates/
│ └── HomePage.rtml
├── host/
│ └── main.go // host server
└── rfw.json // build config
Component Lifecycle
Components can implement OnMount() and OnUnmount(), zero-arg methods auto-wired by composition.New:
func (c *Counter) OnMount() {
c.Count.Set(0)
}
func (c *Counter) OnUnmount() {
c.Count.Set(0)
}
Refs (*t.Ref fields) are resolved from the DOM before OnMount runs.
DI Container
Use *t.Inject[T] fields to auto-fill dependencies from the global container:
composition.Container().Provide("logger", myLogger)
type Page struct {
composition.Component
Logger *t.Inject[Logger]
}
composition.New resolves injectable fields via Container().Get("logger") using the lowercase field name as the key.
v2 vs v1 Summary
| v1 | v2 |
|---|---|
core.NewComponent(name, tpl, map[string]any{}) |
composition.New(&MyStruct{}) |
Manual dom.RegisterHandlerFunc |
Auto-wired via type detection + methods |
c.Props["count"] map lookups |
Typed fields: c.Count.Set(x) |
//go:embed per component |
composition.RegisterFS() globally |
| Client-only rendering | SSC required, host/client split |
| Devtools built-in | Devtools removed |
| Manual prop passing | *t.View fields auto-wire children |
rfw: struct tags |
Type-based detection (no tags needed) |
Related
- Composition, full composition API reference
- Signals & Effects, reactive primitives
- SSC, server-side computed architecture
- State Management, stores, signals, computed values
- Router, routing API