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.

Start with livetemplate.Async instead. For most async work the two-action shape below is more than you need: Async runs the work off the event loop and applies the result in one method, with the goroutine, cancellation and completion render handled for you. Its apply closure receives only (state, result, err) — no *Context — so it cannot raise a flash on the completion render, which is exactly why this pattern keeps two actions: it flashes on both the success and error branch. Reach for the shape below when the second render needs ctx (flash, cookie, navigation), when the work starts outside an action handler, or when it reports progress more than once.

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.
// Why this is NOT written with livetemplate.Async: Async's apply closure
// receives only (state, result, err) — no *Context — so it cannot call
// SetFlash. FetchResult below raises a flash on both branches, which is half
// of what this pattern demonstrates, so the completion step has to be a real
// action. Async is the right tool when the second render only changes state;
// see examples/greet-async.
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-356

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