Lazy Loading

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.

Lazy Loading

The page renders immediately; content arrives from the server ~2s later via a goroutine pushing session.TriggerAction.

Loading content...

Template

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}}

lazy-loading.tmpl

Handler & state

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",
	}))
}

handlers_loading.go:14-125

// LazyLoadState holds the state for the Lazy Loading pattern (#14).
type LazyLoadState struct {
	Title    string
	Category string
	Loading  bool
	Data     string
}

state_loading.go:4-11

When to use

Reach for Async Operations instead when the load is user-triggered and can fail, so you need explicit success and error states.

source: livetemplate/docs · path: examples/patterns/templates/loading/lazy-loading.tmpl