Lazy Loading
The page renders immediately; content arrives from the server ~2s later via a goroutine pushing session.TriggerAction.
Loading content...
Send the shell immediately and fill in the slow part once the data is ready. Mount
returns Loading=true so the first paint shows a spinner; when the live connection
opens, OnConnect spawns a goroutine that simulates a slow API and pushes the payload
back with session.TriggerAction("dataLoaded", …), which clears Loading and
re-renders the region with the content.
One {{if .Loading}} branch: an aria-busy spinner while the data is in flight, then
the loaded content plus a Reload button. The <noscript> note is honest about the
trade-off — with JavaScript off there is no WebSocket, so the spinner never resolves.
{{define "content"}}
<article>
<h3>Lazy Loading</h3>
<p><small>The page renders immediately; content arrives from the server ~2s later via a goroutine pushing <code>session.TriggerAction</code>.</small></p>
<noscript>
<p><small>This pattern requires JavaScript: the spinner is replaced when a WebSocket message arrives. With JS disabled, the spinner stays forever.</small></p>
</noscript>
{{if .Loading}}
<p aria-busy="true">Loading content...</p>
{{else}}
<blockquote>{{.Data}}</blockquote>
<form method="POST">
<button name="reload" class="secondary">Reload</button>
</form>
{{end}}
</article>
{{end}}
OnConnect does the lazy fetch off the connect path and pushes the result with
TriggerAction; DataLoaded writes it into state. It skips re-spawning when the data
has already arrived (e.g. a reconnect), and Reload re-runs the same flow on demand.
// LazyLoadController spawns a goroutine on OnConnect that pushes the lazily-
// loaded payload via session.TriggerAction after a simulated delay. If the
// client reconnects after the payload has already arrived, OnConnect is a
// no-op so the goroutine does not fire a second time.
type LazyLoadController struct{}
// lazyLoadDelay is how long the simulated "slow API" takes before data arrives.
const lazyLoadDelay = 2 * time.Second
func (c *LazyLoadController) Mount(state LazyLoadState, ctx *livetemplate.Context) (LazyLoadState, error) {
// Guard: Mount also fires on POST actions (e.g., Reload). Without this,
// the POST would reset Data/Loading and stomp on the action's own return.
if ctx.Action() == "" {
state.Loading = true
state.Data = ""
}
return state, nil
}
func (c *LazyLoadController) OnConnect(state LazyLoadState, ctx *livetemplate.Context) (LazyLoadState, error) {
// Skip if the data has already arrived (e.g., reconnect after a network
// hiccup) — re-spawning the goroutine would emit a duplicate update.
if !state.Loading {
return state, nil
}
// Session is guaranteed non-nil by livetemplate v0.8.18+ (every connect
// path wires WithSession). The defensive check stays so a future
// framework regression surfaces as "no push happens" rather than a
// panic — but it should NOT be confused with the JS-disabled fallback.
// JS-disabled clients never reach OnConnect at all (no WebSocket = no
// OnConnect call); the JS-disabled spinner-forever case is created by
// Mount() returning Loading=true on the initial HTTP GET. The nil
// branch here is purely a defensive guard against framework bugs.
session := ctx.Session()
if session == nil {
return state, nil
}
// Reconnect-during-loading note: if the client disconnects and
// reconnects within the 2s window, OnConnect fires again and spawns
// a second goroutine while the first is still asleep. Both goroutines
// dispatch via groupID lookup (registry.GetByGroup), and groupID is
// stable across reconnects (cookie-bound), so when each goroutine
// wakes one of two things happens:
// (a) The reconnect hasn't completed yet → GetByGroup returns no
// connections → TriggerAction returns "no connected sessions"
// → goroutine exits via the cancellation pattern below.
// (b) The reconnect has completed → both goroutines successfully
// dispatch to the new connection. DataLoaded runs twice with
// slightly different timestamps; the second call overwrites
// Data. This is harmless — the user just sees the timestamp
// update once. Loading=false is idempotent.
// No explicit dedup guard is needed for this demo. Production code
// that absolutely requires single-flight semantics should track the
// in-flight request ID in state and check it inside DataLoaded.
go func() {
time.Sleep(lazyLoadDelay)
if err := session.TriggerAction("dataLoaded", map[string]any{
"data": "Content loaded lazily at " + time.Now().Format("15:04:05"),
}); err != nil {
return // Session disconnected — stop cleanly.
}
}()
return state, nil
}
func (c *LazyLoadController) DataLoaded(state LazyLoadState, ctx *livetemplate.Context) (LazyLoadState, error) {
state.Data = ctx.GetString("data")
state.Loading = false
return state, nil
}
func (c *LazyLoadController) Reload(state LazyLoadState, ctx *livetemplate.Context) (LazyLoadState, error) {
// Re-entrancy guard, symmetric with ProgressBarController.Start and
// AsyncOpsController.Fetch. The template hides the Reload button while
// Loading=true so a click cannot re-trigger this during the 2s window,
// but a direct WebSocket message bypassing the rendered UI could —
// without this guard, two goroutines would both write state.Data and
// the second timestamp would overwrite the first. Harmless for a demo,
// but the asymmetry would be a trap for readers pattern-matching from
// this file.
if state.Loading {
return state, nil
}
// Check session BEFORE mutating state. With livetemplate v0.8.18+ this
// is always non-nil, but the early return ensures the UI does not
// transition into Loading=true with no goroutine to ever clear it
// — which would happen if the framework's session wiring regressed.
session := ctx.Session()
if session == nil {
return state, nil
}
state.Loading = true
state.Data = ""
go func() {
time.Sleep(lazyLoadDelay)
if err := session.TriggerAction("dataLoaded", map[string]any{
"data": "Content reloaded at " + time.Now().Format("15:04:05"),
}); err != nil {
return // Session disconnected — stop cleanly.
}
}()
return state, nil
}
func lazyLoadingHandler() http.Handler {
tmpl := newLayoutTmpl("templates/layout.tmpl", "templates/loading/lazy-loading.tmpl")
return tmpl.Handle(&LazyLoadController{}, livetemplate.AsState(&LazyLoadState{
Title: "Lazy Loading",
Category: "Loading & Progress",
}))
}
// LazyLoadState holds the state for the Lazy Loading pattern (#14).
type LazyLoadState struct {
Title string
Category string
Loading bool
Data string
}
Reach for Async Operations instead when the load is user-triggered and can fail, so you need explicit success and error states.