Async Operations
A loading → success / error state machine. ~33% simulated failure rate on each fetch.
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.
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}}
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",
}))
}
// 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
}
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.