nojs Framework — Quick Guide¶
A practical reference for using each implemented feature. Examples show the minimal, idiomatic usage pattern for each area.
New to nojs? Start with the Getting Started guide to scaffold a new project, then come back here as a feature reference.
Table of Contents¶
- Core Runtime
- Defining a Component
- StateHasChanged
- Navigate
- Prop Updates via ApplyProps
- Instance Caching
- Component Lifecycle
- OnMount
- OnParametersSet
- OnUnmount
- Dev vs Prod mode
- Signals
- Declaring signals
- Reading and writing
- Subscribing to changes
- Virtual DOM (VDOM)
- Helper Constructors
- Supported Elements
- Boolean Attributes
- Mounting to the DOM
- VDOM Diffing & Patching
- Event System
- Handling Events in Hand-Written Components
- Adapter Functions
- Event Arg Structs
- In Templates (AOT)
- AOT Compiler
- File Convention
- Data Binding
- Ternary Expressions
- Boolean Attribute Shorthand
- Conditional Rendering
- List Rendering
- Event Binding in Templates
- Supported HTML Elements in Templates
- Compile-Time Validation
- Content Projection (Slots)
- Defining a Layout with a Slot
- Using a Layout as a Parent
- Router
- Registering Routes
- Wiring the Router in main()
- Programmatic Navigation
- Layout Reuse (Pivot Algorithm)
- RouterLink Component
- Build System
- JS ↔ Go Interop
1. Core Runtime¶
Defining a Component¶
Every component must implement the Component interface: a Render(Renderer) *vdom.VNode method and embed ComponentBase (which provides SetRenderer).
Prefer templates over hand-written
Render()methods. In normal usage you should never writeRender()by hand. Instead, create aMyComponent.gt.htmltemplate alongside your Go struct and let the AOT compiler generate theRender()method for you (see Section 7 — AOT Compiler). Hand-writingRender()is only necessary for low-level framework internals or components with logic that cannot be expressed in a template (e.g.,AppShell,RouterLink).
The minimal struct — with or without a template — always looks the same:
// mycomponent.go
package mypackage
import (
"github.com/ForgeLogic/nojs/runtime"
)
type MyComponent struct {
runtime.ComponentBase
Title string // exported = prop (passed in by parent)
count int // unexported = private state
}
Pair it with a template file and the compiler generates Render() for you:
<!-- MyComponent.gt.html -->
<div>
<p>{Title}</p>
</div>
The compiler produces MyComponent.generated.go with the Render() method — you never write or edit it directly.
If you are writing Render() by hand (discouraged for normal components):
import "github.com/ForgeLogic/nojs/vdom"
func (c *MyComponent) Render(r runtime.Renderer) *vdom.VNode {
return vdom.Div(nil, vdom.NewVNode("p", nil, nil, c.Title))
}
StateHasChanged¶
Call StateHasChanged() after mutating component state to trigger a re-render. Inherited from ComponentBase.
func (c *MyComponent) Increment() {
c.count++
c.StateHasChanged()
}
If the component is inside a layout slot, StateHasChanged() automatically scopes the re-render to that layout only. For root components it triggers a full re-render.
Navigate¶
Call Navigate(path) from any component to trigger client-side routing without a page reload.
func (c *MyComponent) GoHome() {
c.Navigate("/")
}
Prop Updates via ApplyProps¶
When child components are cached across renders, the framework calls ApplyProps to update their props. The AOT compiler generates this method automatically from exported fields. If writing by hand:
func (c *MyComponent) ApplyProps(src runtime.Component) {
if s, ok := src.(*MyComponent); ok {
c.Title = s.Title
}
}
Instance Caching¶
Child components are reused across re-renders automatically. The renderer keys instances by parent pointer + the template-defined key so component state (e.g., form input values) is preserved between renders.
2. Component Lifecycle¶
Implement any combination of these interfaces on your component struct.
OnMount — run once before first render¶
func (c *MyComponent) OnMount() {
// fetch initial data, start timers, etc.
go c.loadData()
}
OnParametersSet — run before every render (including first)¶
func (c *MyComponent) OnParametersSet() {
// detect prop changes
if c.UserID != c.prevUserID {
c.prevUserID = c.UserID
go c.fetchUser()
}
}
OnUnmount — run once when removed from the tree¶
func (c *MyComponent) OnUnmount() {
c.cancel() // cancel goroutines, release resources
}
Dev vs Prod mode¶
Build tags on renderer_dev.go / renderer_prod.go control panic behaviour:
- Dev (
make full): panics inside lifecycle hooks propagate immediately — easier debugging. - Prod (
make full-prod): panics are recovered and logged — keeps the app alive in production.
No code changes are needed; the build system selects the mode.
3. Signals¶
Signals are reactive, type-safe values that live outside the component tree. They survive route transitions and component remounts, making them the right tool for shared or persistent application state.
See the full Signals documentation for the complete API reference, a comparison with component state, and thread-safety details.
Declaring signals¶
Declare signals as package-level variables in a dedicated appstate package:
// appstate/appstate.go
package appstate
import "github.com/ForgeLogic/nojs/signals"
var Count = signals.NewSignal(0)
var Username = signals.NewSignal("")
Reading and writing¶
current := appstate.Count.Get()
appstate.Count.Set(current + 1) // notifies all subscribers immediately
Subscribing to changes¶
Use Subscribe when a component must react to a signal change while it stays alive in the tree. Always unsubscribe in OnUnmount to prevent dangling references to destroyed components:
func (c *MyComponent) OnMount() {
c.unsub = appstate.Count.Subscribe(func() {
c.localCount = appstate.Count.Get()
c.StateHasChanged()
})
}
func (c *MyComponent) OnUnmount() {
c.unsub()
}
4. Virtual DOM (VDOM)¶
Helper Constructors¶
vdom.Text("hello") // text node
vdom.Paragraph("hello") // <p>hello</p>
vdom.Div(map[string]any{"class": "box"}, child1, ...) // <div class="box">
vdom.Button(map[string]any{}, "Click me") // <button>
vdom.NewVNode("span", map[string]any{"id": "x"}, []*vdom.VNode{child}, "")
Supported Elements¶
#text, p, div, input, button, h1–h6, ul, ol, li, select, option, textarea, form, a, nav, span, section, article, header, footer, main, aside
Boolean Attributes¶
Pass a bool value in the attributes map; the renderer handles the correct HTML boolean rendering.
vdom.NewVNode("input", map[string]any{"disabled": true, "readonly": false}, nil, "")
Mounting to the DOM¶
// In main() or a renderer setup — render to a CSS selector
vdom.RenderToSelector("#app", myVNode)
5. VDOM Diffing & Patching¶
Patching happens automatically when StateHasChanged() or a navigation event triggers a re-render. Key behaviours to be aware of:
- Attribute patching — Only changed attributes are updated; unchanged ones are left alone.
- ComponentKey reconciliation — When
ComponentKeychanges (e.g., the route changes), the entire subtree is replaced and alljs.Funccallbacks are released viadeepReleaseCallbacks(). - Tag replacement — If the tag type changes (e.g.,
<div>→<span>), the DOM node is fully replaced. - Input focus preservation — When an
<input>is focused, its value is not patched to avoid interrupting typing.
No manual diffing API is called from user code; StateHasChanged() and navigation are the only entry points.
6. Event System¶
Handling Events in Hand-Written Components¶
Attach handlers by placing an adapter on the OnClick (or equivalent) field of a VNode:
import "github.com/ForgeLogic/nojs/events"
func (c *MyComponent) HandleClick(e events.ClickEventArgs) {
e.PreventDefault()
c.count++
c.StateHasChanged()
}
func (c *MyComponent) Render(r runtime.Renderer) *vdom.VNode {
btn := vdom.Button(nil, "Click me")
btn.OnClick = events.AdaptClickEvent(c.HandleClick)
return btn
}
Adapter Functions¶
| Adapter | Handler Signature |
|---|---|
AdaptClickEvent |
func(ClickEventArgs) |
AdaptChangeEvent |
func(ChangeEventArgs) |
AdaptKeyboardEvent |
func(KeyboardEventArgs) |
AdaptMouseEvent |
func(MouseEventArgs) |
AdaptFocusEvent |
func(FocusEventArgs) |
AdaptFormEvent |
func(FormEventArgs) |
AdaptNoArgEvent |
func() |
Event Arg Structs¶
All typed arg structs embed EventBase, which provides:
e.PreventDefault()
e.StopPropagation()
ChangeEventArgs.Value holds the new input value. KeyboardEventArgs.Key holds the pressed key string.
In Templates (AOT)¶
Use @event attributes in .gt.html — the compiler selects the correct adapter automatically based on the handler's parameter type:
<button @onclick="HandleClick">Save</button>
<input @oninput="HandleInput" />
<form @onsubmit="HandleSubmit"></form>
7. AOT Compiler¶
The compiler reads *.gt.html template files alongside their Go structs and generates *.generated.go files containing Render() methods. Run it as part of the build:
make full # compile templates + build WASM (dev mode)
make full-prod # compile templates + build WASM (prod mode)
Or run the compiler directly:
go run github.com/ForgeLogic/nojs/cmd/nojs-compiler -in ./app/internal/app/components
File Convention¶
MyComponent.gt.html ← template
mycomponent.go ← struct + methods (no build tags required)
MyComponent.generated.go ← auto-generated, do not edit
Data Binding¶
Bind component fields with {FieldName} in text content or attribute values:
<h1>{Title}</h1>
<p>Count: {Count}</p>
<a href="{Href}">{Label}</a>
Ternary Expressions¶
<p>{IsSaving ? 'Saving...' : 'Save Changes'}</p>
<div class="msg {HasError ? 'error' : 'success'}">Status</div>
Negation is supported: {!IsValid ? 'disabled' : 'enabled'}
Boolean Attribute Shorthand¶
<input disabled="{IsLocked}" />
<button disabled="{!IsValid}">Submit</button>
Conditional Rendering¶
{@if IsLoggedIn}
<p>Welcome back!</p>
{@else if IsGuest}
<p>Browsing as guest.</p>
{@else}
<p>Please log in.</p>
{@endif}
Important: The condition must be a single
boolfield (or state field) on the component struct — the compiler does not evaluate expressions. Comparisons, function calls, and compound conditions (e.g.,Count > 0,len(Items) == 0,A && B) are not supported. If you need complex logic, compute a dedicatedboolfield in your component and use that instead.
go // Do this — pre-compute a named bool field type MyComponent struct { runtime.ComponentBase Items []string HasItems bool // set this in OnParametersSet or a method }
html <!-- Then use the field directly --> {@if HasItems} <ul>...</ul> {@endif}The compiler validates at build time that the named field exists and is of type
bool.
List Rendering¶
{@for i, item := range Items trackBy item}
<li>{i}: {item}</li>
{@endfor}
For slices of structs, use a field as the track key:
{@for i, product := range Products trackBy product.ID}
<li>{i}: {product.Name} (ID: {product.ID})</li>
{@endfor}
Both the index and value variables are required (_ is valid for the index). The trackBy clause is required for correct VDOM reconciliation. Nested {@for} loops are supported.
Event Binding in Templates¶
<button @onclick="IncrementCounter">+</button>
<input @oninput="HandleInput" />
<select @onchange="HandleChange"></select>
The compiler validates at build time that:
- The method exists on the component struct.
- The method's parameter type matches the event (e.g., func(), func(events.ClickEventArgs)).
- The event is valid for the HTML element.
Supported HTML Elements in Templates¶
The compiler has explicit codegen paths for the most common HTML elements (div, p, button, input, select, option, textarea, form, ul, ol, li, h1–h6, a, nav, span, section, article, header, footer, main, aside).
Void/self-closing elements (img, br, hr, wbr) are handled as a dedicated group — they emit vdom.NewVNode(tag, attrs, nil, "") with no children or text content, which matches HTML5 semantics.
Important: Any tag that does not match a known case in the compiler's switch falls through to a
defaultthat emitsvdom.Div(nil)— an empty, attribute-less<div>. This means unrecognised tags are silently replaced with an empty div at compile time, producing no visible output and no error. If an element is not rendering as expected, verify that its tag has an explicit case incompiler/compiler.go. The straightforward fix is to add acasefor the missing tag, or to use one of the already-supported elements.
Compile-Time Validation¶
The compiler reports errors for:
- Unknown field names in {binding} expressions.
- Non-existent event handler methods or wrong signatures.
- Unbalanced {@for}/{@endfor} and {@if}/{@endif} blocks.
- Component names that collide with standard HTML tags (e.g., use RouterLink, not Link).
8. Content Projection (Slots)¶
A layout component exposes a single []*vdom.VNode field as its slot. The field name is irrelevant; the type is the signal.
Defining a Layout with a Slot¶
// mainlayout.go
type MainLayout struct {
runtime.ComponentBase
BodyContent []*vdom.VNode // this field is the slot
}
In the template, render the slot with {BodyContent}:
<!-- MainLayout.gt.html -->
<div>
<header><h1>My App</h1></header>
<main>
{BodyContent}
</main>
</div>
Using a Layout as a Parent¶
The router handles slot injection automatically when a layout appears before a page component in a route chain (see Section 8).
When a page component inside a slot calls StateHasChanged(), the framework detects the slot relationship (tracked in Go memory via SetSlotParent) and triggers a scoped re-render of only the layout, not the entire app.
9. Router¶
Registering Routes¶
// routes.go
routerEngine.RegisterRoutes([]router.Route{
{
Path: "/",
Chain: []router.ComponentMetadata{
{Factory: func(p map[string]string) runtime.Component { return mainLayout }, TypeID: MainLayout_TypeID},
{Factory: func(p map[string]string) runtime.Component { return &pages.HomePage{} }, TypeID: HomePage_TypeID},
},
},
{
Path: "/blog/{year}",
Chain: []router.ComponentMetadata{
{Factory: func(p map[string]string) runtime.Component { return mainLayout }, TypeID: MainLayout_TypeID},
{Factory: func(p map[string]string) runtime.Component {
year, _ := strconv.Atoi(p["year"])
return &pages.BlogPage{Year: year}
}, TypeID: BlogPage_TypeID},
},
},
})
Chainlists components from outermost layout to innermost page.TypeIDis a unique integer per component type, used by the pivot algorithm to detect which layouts can be reused.{year}in the path becomes a key in theparamsmap.
Wiring the Router in main()¶
func main() {
routerEngine := router.NewEngine(nil)
renderer := runtime.NewRenderer(routerEngine, "#app")
routerEngine.SetRenderer(renderer)
registerRoutes(routerEngine, mainLayout, ctx)
appShell := router.NewAppShell(mainLayout)
renderer.SetCurrentComponent(appShell, "app-shell")
renderer.ReRender()
routerEngine.Start(func(chain []runtime.Component, key string) {
appShell.SetPage(chain, key)
})
select {} // keep WASM runtime alive
}
Programmatic Navigation¶
From any component:
func (c *MyComponent) GoToAbout() {
c.Navigate("/about")
}
Layout Reuse (Pivot Algorithm)¶
When navigating between routes that share a layout prefix (e.g., / and /about both use MainLayout), the layout instance is preserved and only the page component is swapped. OnUnmount is called on removed components; OnMount is called on newly created ones.
RouterLink Component¶
Use the built-in RouterLink component in templates for client-side navigation links:
<RouterLink Href="/about">Go to About</RouterLink>
<RouterLink Href="/blog/{item}">Blog {item}</RouterLink>
10. Build System¶
make full # compile AOT templates + build WASM + serve (dev mode, panics propagate)
make full-prod # compile AOT templates + build WASM (prod mode, panics recovered)
make wasm # build WASM only
make serve # serve app/wwwroot on localhost
make clean # remove build artifacts
WASM compilation uses:
GOOS=js GOARCH=wasm go build -o main.wasm
Dev/Prod behaviour is controlled by Go build tags: (js || wasm) && dev for dev, (js || wasm) && !dev for prod. These are set by the Makefile targets automatically.
The workspace uses a go.work file linking the nojs framework module, the compiler module, and the app example module so they can all reference each other locally without publishing to a module proxy.
11. JS ↔ Go Interop¶
Exporting a Go Function to JavaScript¶
// main.go
js.Global().Set("add", js.FuncOf(func(this js.Value, args []js.Value) interface{} {
return args[0].Int() + args[1].Int()
}))
Call from the browser console: window.add(2, 3) → 5
Calling a JavaScript Function from Go¶
js.Global().Call("myJsFunction", "arg1", 42)
Keeping the WASM Runtime Alive¶
The main() function must block after setup or the WASM binary exits:
func main() {
// ... setup ...
select {}
}
Browser API Wrappers¶
Prefer the provided wrapper packages over raw syscall/js:
import "github.com/ForgeLogic/nojs/console"
import "github.com/ForgeLogic/nojs/dialogs"
import "github.com/ForgeLogic/nojs/sessionStorage"
console.Log("value:", 42)
console.Warn("watch out")
console.Error("something failed")
dialogs.Alert("Hello!")
name := dialogs.Prompt("Your name?")
sessionStorage.SetItem("token", "abc123")
token := sessionStorage.GetItem("token")
sessionStorage.RemoveItem("token")
wasm_exec.js and core.js¶
wasm_exec.jsis the vendored Go WASM runtime bridge. Keep it in sync with the Go toolchain version when upgrading Go.core.jsloadsmain.wasm, instantiates the Go runtime, and bootstraps the framework. Do not call exported Go functions beforecore.jshas completedgo.run(...).