Mental Model

The UI runs on the server. You write standard html/template markup and a Go controller; the browser sends ordinary form data. The server runs a controller method, re-renders the template, diffs the result, and updates the browser.

The important constraint is also the useful part: start with HTML that works as a normal form POST, then opt into richer behavior only where the workflow needs it.

One model, every surface

Every reactive thing here is the same four steps:

state changes  →  re-render the template  →  diff against the last render  →  patch the browser

A user clicking a button runs it. A second tab reacting to that click runs it. A different user seeing a live update runs it. The server pushing an update on its own — a timer, a job finishing — runs it. There's no separate real-time engine underneath: a second tab catching up and a background job pushing an update are the same pipeline, entered at a different point. What differs is when the action is enqueued and which connections receive the resulting patch:

What happened When the action runs Who gets the patch
This user clicked Immediately, on their connection This connection
This user started slow work Twice — once now, once when livetemplate.Async work completes This connection
Another tab should follow After the action, via ctx.Publish Same user's other tabs (or any subscribed connections)
Another user should follow After the action, via ctx.Publish to a shared topic Everyone subscribed to that topic
The server decided Whenever the server calls session.TriggerAction The target connection(s)

Because it is one pipeline, the program does not get a new shape as it grows. The counter you build in Your First App and a multi-user booking system are the same program with more controller methods and a Subscribe/Publish call — not a single-page app bolted onto a server. You never learn a second model.

The three files

A small LiveTemplate app usually starts with three files:

The Your First App tutorial uses this shape directly: main.go, counter.go, and counter.tmpl.

What happens on click

Buttons and forms provide the routing key. A button like this:

<button name="increment">+1</button>

dispatches to this controller method:

func (c *CounterController) Increment(state CounterState, ctx *livetemplate.Context) (CounterState, error) {
    state.Count++
    return state, nil
}

The method receives the current state and returns the next state. Return an error and the state does not commit; the template can render the error instead.

Where state lives

The controller is where dependencies live: databases, loggers, clients, and other long-lived services. State is per session group and should contain serializable UI state.

By default, anonymous visitors get a stable session group through a cookie. Tabs from the same browser share that group; different browsers get isolated groups. Authenticated apps can define their own grouping through an authenticator.

Transport fallback

The same controller action can run in three modes:

Browser capability What happens
JavaScript disabled The form submits normally and the browser navigates to the server-rendered response.
JavaScript enabled, WebSocket disabled The client intercepts the form, sends HTTP, and patches the DOM in place.
JavaScript and WebSocket enabled The client sends the action over WebSocket and receives DOM patches on the same connection.

The app code does not need separate handlers for those modes. Progressive enhancement is a transport concern, not a different application model.

DOM updates

After an action changes state, LiveTemplate renders the template on the server and compares it to the previous render. The browser receives the changed parts and patches the current DOM instead of replacing the whole page.

That means templates remain the source of truth. The browser client is there to preserve focus, submit actions, apply patches, and handle optional client attributes; it is not a second application.

Give repeated items a stable data-key and the diff can patch a row in place instead of removing and re-inserting it — see Delete Row for the shape.

This holds for nested structures too. A {{define}} block may invoke itself, so file trees, comment threads, and nested navigation render as ordinary templates and stay inside the reactive tree — editing one leaf five levels down sends a patch addressing that leaf, not its whole branch. The default depth cap is 128 (WithMaxTemplateDepth, or LVT_MAX_TEMPLATE_DEPTH) so self-referential data surfaces an error instead of overflowing the stack. The File Tree recipe is a worked example.

When to use lvt-* attributes

Use plain HTML first:

Reach for lvt-* attributes when HTML cannot express the interaction cleanly: debounced input, instant client-side pending feedback, client-side DOM effects, click-away behavior, or SPA-style navigation that should keep the current LiveTemplate session.

Server-owned loading is deliberately not on that list. livetemplate.Async runs slow work off the event loop and {{.lvt.Pending}} renders the spinner, both through ordinary template conditionals — no attributes involved.

When to use pub/sub

You don't need pub/sub for a single form updating a single tab. Add it when another connection needs to react to an action.

The smallest common case is same-user multi-tab sync:

func (c *Controller) Mount(state State, ctx *livetemplate.Context) (State, error) {
    _ = ctx.Subscribe(ctx.SelfTopic())
    return state, nil
}

func (c *Controller) Save(state State, ctx *livetemplate.Context) (State, error) {
    // mutate state or durable storage
    ctx.Publish(ctx.SelfTopic(), "Refresh", nil)
    return state, nil
}

Subscribe opts the current connection into a topic. Publish sends an action to subscribed peers after the current action succeeds. Without both parts, no peer update happens.

Next

source: livetemplate/docs · path: content/getting-started/mental-model.md