The Controller+State pattern separates concerns in LiveTemplate applications:
This separation ensures dependencies are shared correctly while state is isolated per user session.
// CONTROLLER: Singleton, holds dependencies, never cloned
type TodoController struct {
DB *sql.DB
Logger *slog.Logger
}
// STATE: Pure data, cloned per session
type TodoState struct {
Items []Todo
Filter string
}
// Mount handler (controller, state wrapper)
handler := tmpl.Handle(controller, livetemplate.AsState(&TodoState{}))
Actions are automatically dispatched to methods matching the action name:
// Template: <button name="add"> OR lvt-on:click="add"
// Dispatches to: Add() method
func (c *TodoController) Add(state TodoState, ctx *livetemplate.Context) (TodoState, error) {
title := ctx.GetString("title")
state.Items = append(state.Items, Todo{Title: title})
return state, nil
}
Key features:
LiveTemplate provides two conventional method names that are auto-routed without any explicit attributes:
Submit() — Called when a form submits with no explicit action routing (lvt-form:action, button name, or form name):
// Template: <form method="POST"><button type="submit">Save</button></form>
// Auto-routes to Submit() because no action is specified
func (c *Controller) Submit(state State, ctx *livetemplate.Context) (State, error) {
var input struct {
Title string `validate:"required,min=3"`
}
if err := ctx.BindAndValidate(&input, validate); err != nil {
return state, err
}
// process...
return state, nil
}
Change() — Called when a form input with a dynamic value changes. The server detects this method and sends capabilities: ["change"] in the initial render; the client auto-wires debounced input events (300ms default). No lvt-* attributes needed. Optional:
// Auto-routes when an input with value="{{.Field}}" changes
func (c *Controller) Change(state State, ctx *livetemplate.Context) (State, error) {
if ctx.Has("Name") { state.Name = ctx.GetString("Name") }
return state, nil
}
Actions can be routed using standard HTML attributes instead of lvt-*:
| HTML Pattern | Routed To |
|---|---|
<form> (no attributes) |
Submit() |
<button name="save"> |
Save() |
<form name="search"> |
Search() (JS client only; form.name is not sent in a non-JS POST) |
lvt-form:action="create" |
Create() (explicit Tier 2 routing) |
lvt-on:click="delete" |
Delete() (for non-form interactions) |
All action methods follow the same signature:
func (c *ControllerType) ActionName(state StateType, ctx *livetemplate.Context) (StateType, error)
| Action Name | Method Name |
|---|---|
increment |
Increment() |
addItem |
AddItem() |
add_item |
AddItem() |
setUserProfile |
SetUserProfile() |
Action names are case-insensitive and support both camelCase and snake_case.
LiveTemplate provides lifecycle hooks for initialization and connection management.
Called on every HTTP request (GET and POST) and every WebSocket connect (new and reconnect):
func (c *TodoController) Mount(state TodoState, ctx *livetemplate.Context) (TodoState, error) {
// Load initial data
items, err := c.DB.GetTodosForUser(ctx.UserID())
if err != nil {
return state, fmt.Errorf("failed to load todos: %w", err)
}
state.Items = items
return state, nil
}
Use cases:
Guarding side effects per connect kind: Mount runs on HTTP GET, HTTP POST actions, WebSocket new-connect, and WebSocket reconnect. Use ctx.IsInitialMount() to limit one-time setup (e.g. starting a background goroutine) to initial page loads, and ctx.IsReconnect() to detect a WebSocket reconnect that restored persisted state:
func (c *TodoController) Mount(state TodoState, ctx *livetemplate.Context) (TodoState, error) {
if ctx.IsInitialMount() {
// Only on initial HTTP GET — not POST actions, not WS connects.
state.Loading = true
go c.warmCacheFor(ctx.UserID())
}
return state, nil
}
func (c *ChatController) OnConnect(state ChatState, ctx *livetemplate.Context) (ChatState, error) {
if ctx.IsReconnect() {
// Network blipped — re-announce presence, skip re-fetches the prior
// connection already completed.
state.SystemMessages = append(state.SystemMessages, "[reconnected]")
}
return state, nil
}
The older ctx.Action() == "" idiom still works (it returns true for GET, internal navigate POSTs, and WS connects/reconnects), but the new helpers disambiguate the four lifecycle paths and make intent obvious to readers.
Called when a WebSocket connection is established:
func (c *TodoController) OnConnect(state TodoState, ctx *livetemplate.Context) (TodoState, error) {
c.Logger.Info("WebSocket connected", "user", ctx.UserID())
// Store session for server-initiated updates
session := ctx.Session()
if session != nil {
go c.sendWelcomeMessage(session)
}
return state, nil
}
Use cases:
Heads-up — ctx.IsReconnect() on the first WS: When a browser does the normal flow (HTTP GET → server-renders → WebSocket connects), the framework persists state at the end of the HTTP-path Mount, then restores that state when the WebSocket opens. The WS-path OnConnect therefore sees ctx.IsReconnect() == true even though no prior WebSocket connection ever existed. If your OnConnect needs to distinguish "brand-new session" from "WS resumed after a blip", pair IsReconnect() with IsNewConnect() or gate on per-session state. See the IsReconnect godoc for the full semantics.
Called when a WebSocket connection is closed:
func (c *TodoController) OnDisconnect() {
c.Logger.Info("WebSocket disconnected")
}
Use cases:
For the complete Context API (data extraction, HTTP operations, struct binding), see API Reference — Context.
For validation errors, field errors, and template error display, see Error Handling Reference.
type CounterState struct {
Count int
}
type CounterController struct {
Logger *slog.Logger
}
func (c *CounterController) Increment(state CounterState, ctx *livetemplate.Context) (CounterState, error) {
state.Count++
c.Logger.Info("counter incremented", slog.Int("count", state.Count))
return state, nil
}
func (c *CounterController) Decrement(state CounterState, ctx *livetemplate.Context) (CounterState, error) {
if state.Count > 0 {
state.Count--
}
return state, nil
}
type TodoState struct {
Items []Todo
}
type Todo struct {
ID string
Title string
Completed bool
}
type TodoController struct {
DB *sql.DB
}
func (c *TodoController) Mount(state TodoState, ctx *livetemplate.Context) (TodoState, error) {
items, err := c.DB.GetTodos()
if err != nil {
return state, fmt.Errorf("failed to load todos: %w", err)
}
state.Items = items
return state, nil
}
func (c *TodoController) Add(state TodoState, ctx *livetemplate.Context) (TodoState, error) {
title := strings.TrimSpace(ctx.GetString("title"))
if title == "" {
return state, livetemplate.NewFieldError("title", errors.New("title required"))
}
todo := Todo{
ID: uuid.New().String(),
Title: title,
}
if err := c.DB.InsertTodo(todo); err != nil {
return state, fmt.Errorf("database error")
}
state.Items = append(state.Items, todo)
return state, nil
}
func (c *TodoController) Toggle(state TodoState, ctx *livetemplate.Context) (TodoState, error) {
id := ctx.GetString("id")
for i := range state.Items {
if state.Items[i].ID == id {
state.Items[i].Completed = !state.Items[i].Completed
return state, c.DB.UpdateTodo(state.Items[i])
}
}
return state, fmt.Errorf("todo not found")
}
func (c *TodoController) Delete(state TodoState, ctx *livetemplate.Context) (TodoState, error) {
id := ctx.GetString("id")
for i, todo := range state.Items {
if todo.ID == id {
if err := c.DB.DeleteTodo(id); err != nil {
return state, fmt.Errorf("database error")
}
state.Items = append(state.Items[:i], state.Items[i+1:]...)
return state, nil
}
}
return state, fmt.Errorf("todo not found")
}
type NotificationState struct {
Messages []string
}
type NotificationController struct {
sessions sync.Map // userID -> Session
}
func (c *NotificationController) OnConnect(state NotificationState, ctx *livetemplate.Context) (NotificationState, error) {
if session := ctx.Session(); session != nil {
c.sessions.Store(ctx.UserID(), session)
}
return state, nil
}
func (c *NotificationController) OnDisconnect() {
// Session cleanup handled by LiveTemplate
}
// Call from anywhere in your application
func (c *NotificationController) NotifyUser(userID, message string) {
if session, ok := c.sessions.Load(userID); ok {
session.(livetemplate.Session).TriggerAction("addMessage", map[string]interface{}{
"message": message,
})
}
}
func (c *NotificationController) AddMessage(state NotificationState, ctx *livetemplate.Context) (NotificationState, error) {
message := ctx.GetString("message")
state.Messages = append(state.Messages, message)
return state, nil
}
Peer fan-out is opt-in. Each connection that wants to receive peer updates subscribes to a topic in Mount; actions that mutate shared state publish to that topic, and every subscribed peer dispatches the named action with its own state.
The canonical "broadcast to my own session" pattern uses ctx.SelfTopic() — a reserved-namespace topic (lvt:session:<groupID>) that resolves to this session's own connections. SelfTopic() is ACL-exempt by construction; you can always Subscribe(SelfTopic()) without a WithTopicACL rule.
func (c *ChatController) Mount(state ChatState, ctx *livetemplate.Context) (ChatState, error) {
// Opt this connection in to peer fan-out for the session.
// SelfTopic() is ACL-exempt, idempotent across re-Mounts.
_ = ctx.Subscribe(ctx.SelfTopic())
return state, nil
}
func (c *ChatController) Send(state ChatState, ctx *livetemplate.Context) (ChatState, error) {
c.mu.Lock()
c.messages = append(c.messages, Message{User: state.CurrentUser, Text: ctx.GetString("message")})
c.mu.Unlock()
state.Messages = c.copyMessages()
ctx.Publish(ctx.SelfTopic(), "RefreshMessages", nil) // fans out to subscribed peers
return state, nil
}
func (c *ChatController) RefreshMessages(state ChatState, ctx *livetemplate.Context) (ChatState, error) {
state.Messages = c.copyMessages() // each connection's CurrentUser is preserved
return state, nil
}
Ordering. Publish queues onto a per-action drain. Call it after every ctx.With*() shallow-copy mutation; publishes queued before a With*() are stranded on the pre-copy Context and won't propagate.
Cap. A single action can enqueue at most MaxPublishesPerAction (declared in topic_context.go) Publish calls before subsequent calls become hard errors.
Beyond SelfTopic(), controllers can subscribe to developer-defined topics (e.g. "room/lobby"). Developer topics are deny-by-default — every Subscribe(developerTopic) runs the WithTopicACL(fn) hook, and a denied call returns *TopicForbiddenError. Two patterns matter for controllers:
(1) Guard with IsInitialMount when the ACL may deny. Mount runs on HTTP GET, HTTP POST, WS connect, and WS reconnect. A denied Subscribe on the HTTP GET path surfaces as HTTP 500 — the page aborts before the WS can exercise the keep-open path. Guard side-effects (including potentially-denied Subscribes) with the connect-kind helpers so the GET render does not call Subscribe until the WS is the lifecycle owner:
func (c *RoomController) Mount(state RoomState, ctx *livetemplate.Context) (RoomState, error) {
_ = ctx.Subscribe(ctx.SelfTopic()) // always safe, ACL-exempt
if !ctx.IsInitialMount() {
// Only subscribe to the gated topic on WS connect / reconnect /
// POST actions — never on the initial HTTP GET render.
if err := ctx.Subscribe("room/" + state.RoomID); err != nil {
// Propagate the error to surface lvt:error on the client (see below).
return state, err
}
}
return state, nil
}
(2) Propagate the error to surface lvt:error. When a controller propagates a *TopicForbiddenError from Mount on the WS-connect path, the server emits a {"type":"error","code":"topic_forbidden","topic":<denied>} envelope, logs a structured warning, and keeps the connection open — adopting the controller's returned state and continuing the normal mount lifecycle (persistState, OnConnect, initial-tree send). The client dispatches a lvt:error CustomEvent on the [data-lvt-id] wrapper element. See the Client Error Envelope section in the PubSub reference for the wire-level contract.
(3) Don't swallow the error if you want it surfaced. A controller that swallows the denied Subscribe — _ = ctx.Subscribe("denied"); return s, nil — emits no envelope. The Phase 4 contract is on the propagated-error scenario only; if you want the client to see lvt:error, return the error. Returning nil is a quiet allow-and-continue.
(4) Don't mutate s before a may-deny Subscribe. Because keep-open adopts the controller's returned newState, a partial mutation before a denied Subscribe persists silently (it isn't rolled back). The rule of thumb:
To surface the envelope, propagate the error (
return s, err). To keep state clean, don't mutatesbefore aSubscribethat may be denied.
// Create controller with dependencies
controller := &TodoController{
DB: db,
Logger: logger,
}
// Create initial state
initialState := &TodoState{
Items: []Todo{},
}
// Create template
tmpl := livetemplate.New("todos")
// Register handler
handler := tmpl.Handle(controller, livetemplate.AsState(initialState))
// Mount to HTTP server
http.Handle("/", handler)
For file upload configuration and handling, see Upload Reference.
Use AssertPureState[T]() as a sanity check to catch common dependency types accidentally added to state structs (this is a heuristic, not a comprehensive serializability check):
func TestState(t *testing.T) {
// Fails if TodoState contains *sql.DB, *slog.Logger, etc.
livetemplate.AssertPureState[TodoState](t)
}