Async Operations

The minimal shape for any async RPC — a database query, an HTTP call, a queued job. Fetch sets Status="loading" synchronously and spawns a goroutine that waits, then pushes a fetchResult action with either a success payload or an error (this demo fails ~33% of the time at random). The FetchResult action moves Status to success or error and stashes the result or message, so the four states — idle, loading, success, error — are all just values the template branches on.

Async Operations

A loading → success / error state machine. ~33% simulated failure rate on each fetch.

Template

The button disables itself and shows "Fetching..." while Status is loading. Success and error each get a FlashTag alert; the result renders in a <blockquote> and the error string in an aria-live <mark> for assistive tech.

{{define "content"}}
<article>
    <h3>Async Operations</h3>
    <p><small>A loading → success / error state machine. ~33% simulated failure rate on each fetch.</small></p>
    <form method="POST">
        <button name="fetch"
            {{if eq .Status "loading"}}aria-busy="true" disabled{{end}}>
            {{if eq .Status "loading"}}Fetching...{{else}}Fetch Data{{end}}
        </button>
    </form>
    {{.lvt.FlashTag "success"}}
    {{.lvt.FlashTag "error"}}
    {{with .Result}}<blockquote>{{.}}</blockquote>{{end}}
    {{/* <mark> is the sanctioned choice for secondary inline error details
         (see CLAUDE.md "Use <mark> for highlighted/badge text and for
         secondary inline error details"). The FlashTag above is the
         primary error alert with role="alert"; <mark> highlights the
         specific error string. */}}
    {{with .Error}}<p aria-live="assertive"><mark>{{.}}</mark></p>{{end}}
</article>
{{end}}

async-operations.tmpl

Handler & state

Fetch guards against a second in-flight request, checks the session before mutating, then pushes a fetchResult from the goroutine; FetchResult resolves the state machine and sets the matching flash.

// AsyncOpsController implements a loading/success/error state machine. The
// Fetch action transitions to "loading" synchronously, then a goroutine waits
// and pushes a "fetchResult" action with either a success payload or an error
// payload. Demonstrates the minimal state-machine shape you'd use for any
// async RPC (database query, HTTP API, job queue, etc.).
//
// Reconnect semantics — why no OnConnect (same reasoning as ProgressBarController):
// AsyncOpsState has no `lvt:"persist"` tags, so a reconnect mid-fetch produces
// fresh zero-value state (Status="") via cloneStateTyped, not a stuck
// Status="loading". The user always sees the Fetch Data button after a
// reconnect. The in-flight goroutine's eventual TriggerAction either lands on
// the new connection (showing a result the user didn't initiate — harmless,
// since this is a demo) or errors out cleanly when the goroutine's session
// is gone. Adding OnConnect to "recover" loading state would actively make
// this worse by trying to restore Status="loading" against a goroutine that
// the framework has already torn down.
type AsyncOpsController struct{}

const asyncFetchDelay = 2 * time.Second

func (c *AsyncOpsController) Fetch(state AsyncOpsState, ctx *livetemplate.Context) (AsyncOpsState, error) {
	// Re-entrancy guard: block concurrent Fetch while one is already in
	// flight. The button is template-disabled during loading, but a direct
	// WebSocket message bypassing the rendered UI could otherwise spawn
	// two parallel goroutines that both call TriggerAction("fetchResult"),
	// producing two state transitions and two SetFlash calls on the same
	// session. Mirrors the Running guard in ProgressBarController.Start.
	if state.Status == "loading" {
		return state, nil
	}
	// Check session BEFORE setting Status="loading". With livetemplate
	// v0.8.18+ this is always non-nil, but if it ever became nil the
	// previous ordering (mutate first, check second) would leave the
	// button stuck showing "Fetching..." with no goroutine to clear it.
	session := ctx.Session()
	if session == nil {
		return state, nil
	}
	state.Status = "loading"
	state.Result = ""
	state.Error = ""
	go func() {
		time.Sleep(asyncFetchDelay)
		// Simulated ~33% failure rate. Non-deterministic between runs because
		// Go 1.20+ auto-seeds top-level math/rand from a system source at
		// program startup — no rand.Seed call is needed. Tests must assert
		// {success OR error}, not a specific branch, since either may fire
		// on any given run.
		//
		// Both branches use the same `if err := …; err != nil { return }`
		// pattern as the other controllers for consistency, even though
		// this is a single-shot goroutine where there's nothing else to
		// cancel — readers learning the pattern from this example should
		// see the idiomatic form everywhere.
		if rand.Intn(3) == 0 {
			if err := session.TriggerAction("fetchResult", map[string]any{
				"success": false,
				"error":   "Connection timed out",
			}); err != nil {
				return // Session disconnected — stop cleanly.
			}
		} else {
			if err := session.TriggerAction("fetchResult", map[string]any{
				"success": true,
				"result":  "Data fetched successfully at " + time.Now().Format("15:04:05"),
			}); err != nil {
				return // Session disconnected — stop cleanly.
			}
		}
	}()
	return state, nil
}

func (c *AsyncOpsController) FetchResult(state AsyncOpsState, ctx *livetemplate.Context) (AsyncOpsState, error) {
	if ctx.GetBool("success") {
		state.Status = "success"
		state.Result = ctx.GetString("result")
		state.Error = ""
		ctx.SetFlash("success", "Fetch complete", livetemplate.FlashExpiry(flashSuccessExpiry))
		nudgeFlashExpiry(ctx, flashSuccessExpiry)
	} else {
		state.Status = "error"
		state.Error = ctx.GetString("error")
		state.Result = ""
		ctx.SetFlash("error", "Fetch failed")
	}
	return state, nil
}

func (c *AsyncOpsController) Refresh(state AsyncOpsState, ctx *livetemplate.Context) (AsyncOpsState, error) {
	return state, nil
}

func asyncOperationsHandler() http.Handler {
	tmpl := newLayoutTmpl("templates/layout.tmpl", "templates/loading/async-operations.tmpl")
	return tmpl.Handle(&AsyncOpsController{}, livetemplate.AsState(&AsyncOpsState{
		Title:    "Async Operations",
		Category: "Loading & Progress",
	}))
}

handlers_loading.go:250-350

// AsyncOpsState holds the state for the Async Operations pattern (#16).
// Status is a simple state machine: "" (idle), "loading", "success", "error".
type AsyncOpsState struct {
	Title    string
	Category string
	Status   string
	Result   string
	Error    string
}

state_loading.go:37-46

When to use

Reach for Progress Bar when the operation has a measurable percentage to report, or Lazy Loading when the content should load automatically after first paint rather than on a click.

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