| name | hlive-core |
| description | Build UIs with HLive, the server-side virtual-DOM framework for Go (github.com/SamHennessy/hlive). Use when working with HLive code — the `l "github.com/SamHennessy/hlive"` import, builders like l.T / l.C / l.CM, NewPage / NewPageServer, On(...) event bindings, l.Box reactive values, or questions about HLive components, rendering, the SSR-vs-WebSocket lifecycle, and tree-diff gotchas. |
HLive Core
HLive is a server-side virtual-DOM framework for Go — think Phoenix LiveView for
Go. You build the UI as a tree of Go values; HLive renders the initial HTML, then
keeps a WebSocket open and pushes DOM diffs whenever your state changes. All logic
(DB, business rules, API calls) stays in Go — you write no JavaScript.
The conventional import alias is l:
import l "github.com/SamHennessy/hlive"
Builders
For everyday page markup, prefer the hhtml builders (Div(...),
Button(...), Class("...")) — they're typed and autocompleting. See the
hlive-hhtml skill. The functions below are the underlying primitives that
hhtml wraps; use them directly for dynamic tag names, or when you haven't
imported hhtml.
| Func | Returns | Use for |
|---|
l.T(name, ...elements) | *Tag | A static HTML tag (no events). e.g. l.T("div", ...). Void tags like hr/img have no children. |
l.C(name, ...elements) | *Component | An event-aware tag — anything the user interacts with (button, input). |
l.W(tag, ...elements) | *Component | Wrap an existing *Tag as a Component to attach events. |
l.CM(name, ...elements) | *ComponentMountable | A Component with lifecycle hooks (Mount/Unmount). Use when the component needs to fetch data when it appears. |
l.G(...nodes) | *NodeGroup | Group sibling nodes without a wrapper element. |
l.E(...elements) | *ElementGroup | Group a mix of nodes + attributes + bindings. |
With hhtml you rarely choose T vs C by hand — a builder like Button(...)
becomes a Component automatically when it contains an event binding. With the raw
primitives, pick T until the element needs to respond to a browser event, then
C; reach for CM only when you need Mount/Unmount.
Events
There are exactly two event-binding constructors: l.On and l.OnOnce.
There are no per-event helpers like OnClick/OnKeyUp — pass the DOM event name
as a string.
Button(
l.On("click", func(ctx context.Context, e l.Event) {
}),
"Save",
)
Input(
Type("text"),
l.On("keyup", func(ctx context.Context, e l.Event) {
message = e.Value
}),
)
(Button/Input/Type are hhtml builders; the binding makes them Components
automatically. The equivalent with raw primitives is l.C("button", l.On(...)).)
l.OnOnce("focus", ...) binds a handler that fires once then removes itself.
The l.Event struct carries (only the fields relevant to the event type are set):
Value string / Values []string — input value(s)
Selected bool — checkbox/radio checked, or select option selected
Key, CharCode, KeyCode, ShiftKey, AltKey, CtrlKey — keyboard events
File *l.File — file inputs/uploads
IsInitial bool — true when a browser re-sent pre-filled field data after a
page reload (see Initial sync below)
Reactive state with Box
l.Box(v) wraps a value in a thread-safe container that is also a renderable
node — drop it into the tree and it renders its current value. Read/write it
safely with Get, Set, and Lock:
count := l.Box(0)
Button(
l.On("click", func(_ context.Context, _ l.Event) {
count.Lock(func(v int) int { return v + 1 })
}),
count,
)
For a plain variable you can also pass it by reference so renders pick up the
latest value: P("Hello, ", &message). Box is preferred when more than one
goroutine (e.g. PubSub) can touch the value.
Attributes and styling
l.Attrs{"type": "text", "placeholder": "name"}
l.Class("btn btn-primary")
l.ClassBool{"active": isActive, "disabled": !ok}
l.ClassList{"a", "b", "c"}
l.Style{"color": "red", "display": nil}
Ordering note: within a single ClassBool/Style the order is not preserved;
add separate elements when order matters. Re-adding the same class key updates the
existing one.
With hhtml, set plain attributes with the typed funcs (Type("text"),
Placeholder("name"), Class("btn")) instead of l.Attrs; keep the core
l.ClassBool/l.ClassList/l.ClassOff/l.Style types for toggling classes
and styles on/off.
Page and server
import (
l "github.com/SamHennessy/hlive"
. "github.com/SamHennessy/hlive/hhtml"
)
func home() *l.Page {
page := l.NewPage()
page.DOM().Title().Add("My App")
page.DOM().Head().Add(Link(Rel("stylesheet"), Href("/assets/main.css")))
page.DOM().Body().Add( )
return page
}
func main() {
http.Handle("/", l.NewPageServer(home))
http.ListenAndServe(":3000", nil)
}
page.DOM() is a method that returns the document; .Title(), .Head(),
.Body(), and .HTML() are methods on it. (Some older README snippets show
page.DOM.Body as a field — that's stale; always call page.DOM().Body().)
Rendering model
- AutoRender is on by default: every triggered event binding re-renders and
diffs the page automatically.
- To render manually, set a component's AutoRender off and call
hlive.Render(ctx) (re-renders the whole page).
- To re-render just one component and its children, call
hlive.RenderComponent(ctx, comp). This is powerful but easy to get subtly
wrong — only reach for it when whole-page renders are a measured problem.
The ctx passed to your handler is what Render/RenderComponent need.
Lifecycle (CM components)
cm := l.CM("table", )
cm.SetMount(func(ctx context.Context) {
})
cm.SetUnmount(func(ctx context.Context) { })
Mount is the place to load per-session data — including values stashed in
ctx by middleware.
Gotchas (read before debugging weird diffs)
- SSR vs WebSocket. Each page load is two requests: an initial HTML render
(SSR —
Mount is not called) and then a WebSocket connection (where
Mount runs and live diffing begins). The WS connects to the same URL with
?hlive=1. One Page instance = one connected user.
GetNodes must be deterministic. A Tag's children are read many times,
not only at render. Do no I/O and make no state changes when building
children — fetch data in event handlers or Mount, not while constructing the
tree.
- HLive is blind to the real browser DOM. It assumes the DOM matches what it
last set. If your own JavaScript mutates the DOM, diffing can break.
- Invalid HTML / browser quirks break path-finding. Browsers silently drop
invalid HTML and "fix" some structures (e.g. injecting
<tbody>, merging
adjacent text nodes). HLive locates elements by path, so these surprises can
make updates land in the wrong place. Keep markup valid and well-formed.
- Initial sync. Some browsers (e.g. Firefox) keep form field values across a
reload. HLive resends that data; an input needs an event binding to receive it,
and you can detect it with
e.IsInitial.
Worked example (interactive counter)
import (
"context"
l "github.com/SamHennessy/hlive"
. "github.com/SamHennessy/hlive/hhtml"
)
func home() *l.Page {
count := l.Box(0)
page := l.NewPage()
page.DOM().Title().Add("Click")
page.DOM().Body().Add(
P("Clicks: ",
Button(
l.On("click", func(_ context.Context, _ l.Event) {
count.Lock(func(v int) int { return v + 1 })
}),
count,
),
),
)
return page
}
Reference
Source of truth for current API is the runnable examples and core source, not
prose docs:
_example/click/click.go, _example/todo/todo.go, _example/url_params/url_params.go
component.go, componentMountable.go, tag.go, page.go, dom.go, event.go, hlive.go
For building markup with the typed hhtml builders, see the hlive-hhtml skill.
For laying out a multi-page app, see the hlive-project skill. For dynamic
lists, focus, real-time updates, and diff-apply callbacks, see the hlivekit
skill. For browser tests, see the hlive-testing skill.