LiveTemplate follows a two-tier progressive complexity model:
lvt-* Attributes — debounce, reactive DOM, lifecycle hooks. Only when HTML can't express it.This guide walks through Tier 1 from the simplest case to full-featured applications.
A form inside a LiveTemplate handler just works. No lvt-* attributes, no hidden fields, no special setup:
<form method="POST">
<input type="text" name="Title" placeholder="New todo...">
<button type="submit">Add</button>
</form>
func (c *Controller) Submit(state State, ctx *livetemplate.Context) (State, error) {
title := ctx.GetString("Title")
state.Items = append(state.Items, Todo{Title: title})
return state, nil
}
What happens: The framework auto-intercepts all forms. When no action is specified, it routes to Submit(). This works at all three transport levels: no-JS (POST + page reload), fetch (DOM patch), and WebSocket.
When a form needs multiple actions, the button name IS the action:
<form method="POST">
<input type="text" name="Title" value="{{.Title}}">
<button name="save">Save</button>
<button name="save-draft" formnovalidate>Save Draft</button>
</form>
func (c *Controller) Save(state State, ctx *livetemplate.Context) (State, error) {
// Validated save
return state, nil
}
func (c *Controller) SaveDraft(state State, ctx *livetemplate.Context) (State, error) {
// Save without validation (formnovalidate skips HTML validation)
return state, nil
}
The clicked button's name determines which method is called. Button value becomes data:
{{range .Items}}
<form method="POST">
<input type="hidden" name="id" value="{{.ID}}">
<span>{{.Title}}</span>
<button name="toggle">{{if .Done}}Undo{{else}}Done{{end}}</button>
<button name="delete" value="{{.ID}}">Delete</button>
</form>
{{end}}
func (c *Controller) Toggle(state State, ctx *livetemplate.Context) (State, error) {
id := ctx.GetString("id") // from hidden input
// toggle item...
return state, nil
}
func (c *Controller) Delete(state State, ctx *livetemplate.Context) (State, error) {
id := ctx.GetString("value") // from button value
// delete item...
return state, nil
}
Buttons with a name attribute work as actions even outside any <form> (requires JS client — fetch or WebSocket):
<h1>Counter: {{.Counter}}</h1>
<button name="increment">+</button>
<button name="decrement">-</button>
The button's name routes to the corresponding Go method. Button value and data-* attributes are sent as action data:
<button name="delete" value="{{.ID}}">Delete</button>
<button name="edit" data-id="{{.ID}}" data-mode="quick">Quick Edit</button>
No-JS fallback: For progressive enhancement without JavaScript, wrap buttons in a
<form method="POST">instead (see Section 2).
Note: The form schema is auto-wired from your template's HTML attributes, so
ctx.ValidateForm()works without callingWithFormSchemamanually. For production validation with custom rules, usectx.BindAndValidate()with struct tags.
HTML validation attributes (required, pattern, min, max, minlength, maxlength, type) can be extracted by the framework. Use ctx.ValidateForm() instead of writing Go struct tags:
<form method="POST">
<input type="email" name="Email" required minlength="5" maxlength="100">
{{if .lvt.HasError "email"}}
<span class="error">{{.lvt.Error "email"}}</span>
{{end}}
<input type="number" name="Age" min="18" max="120">
{{if .lvt.HasError "age"}}
<span class="error">{{.lvt.Error "age"}}</span>
{{end}}
<input type="text" name="Code" pattern="[A-Z]{3}">
<button type="submit">Submit</button>
</form>
func (c *Controller) Submit(state State, ctx *livetemplate.Context) (State, error) {
if err := ctx.ValidateForm(); err != nil {
return state, err // Errors auto-displayed via .lvt.HasError/.lvt.Error
}
// All fields valid
state.Email = ctx.GetString("Email")
state.Age = ctx.GetInt("Age")
return state, nil
}
No Go struct tags needed. The required, type="email", minlength="5", min="18" attributes are the validation rules.
Use formnovalidate on a submit button to skip ctx.ValidateForm() for that
submit path — the canonical "save draft" flow:
<button name="save">Save</button>
<button name="save-draft" formnovalidate>Save Draft</button>
The framework reads the formnovalidate button's name from your template and
skips validation when that button is the submitter — on every tier (WebSocket,
HTTP-fetch, and no-JS native POST). Three things to know:
name; a dynamic
({{...}}) name can't be detected.value. On the pure no-JS tier the server identifies the
submitter by its empty-value form field, so a formnovalidate button that
carries a value won't be recognized as the submitter there (JS tiers send an
explicit submitter and are unaffected).Use the standard <dialog> element with command/commandfor for native modal dialogs:
<!-- Open button -->
<button command="show-modal" commandfor="edit-dialog">Edit</button>
<!-- Dialog with form -->
<dialog id="edit-dialog">
<form name="save">
<h2>Edit Item</h2>
<input name="title" value="{{.Title}}">
<input type="hidden" name="id" value="{{.ID}}">
<button type="submit">Save</button>
<button type="button" command="close" commandfor="edit-dialog">Cancel</button>
</form>
</dialog>
command="show-modal" opens the dialog via .showModal() — backdrop, focus trapping, and Escape key handling are all native to <dialog>command="close" closes it via .close()<dialog> when a form submission succeeds — so the dialog stays open for validation errors but closes on successcommand/commandfor for browsers that don't yet support the Invoker Commands API natively (Firefox, Safari). The polyfill uses feature detection (commandForElement) and becomes a no-op when browsers add native supportLinks inside the LiveTemplate wrapper are auto-intercepted for SPA navigation:
<nav>
<a href="/todos">Todos</a>
<a href="/profile">Profile</a>
<a href="/settings">Settings</a>
</nav>
The framework fetches the page via fetch(), extracts the wrapper content, and replaces the DOM. No full page reload. Browser history (pushState) is updated automatically.
Opt-out for links that should navigate normally:
<a href="/api/export.csv" download>Export</a> <!-- download attr: skipped -->
<a href="https://external.com">External</a> <!-- different origin: skipped -->
<a href="/legacy-page" lvt-nav:no-intercept>Old Page</a> <!-- explicit opt-out -->
LiveTemplate offers three loading models. Pick the simplest one that fits your use case:
| I want… | Accepts lvt-* attrs? |
Path | Go boilerplate |
|---|---|---|---|
| Grey out the form | N/A | 7.1 — Auto (<fieldset> + CSS) |
0 lines, 0 attrs |
| Custom loading UX (spinner, text) | Yes | 7.2 — Client-owned pending (lvt-el:*:on:pending) |
0 lines, 2 attrs |
| Custom loading UX (spinner, text) | No | 7.3 — Server-owned loading with Async + {{.lvt.Pending}} |
~7 lines, 0 attrs |
| Loading fans out to peers / survives reconnect | Either | 7.3 — Server-owned loading with Async + a Loading field |
~9 lines, 0 attrs |
Work starts in Mount/OnConnect, or reports progress repeatedly |
Either | 7.3 — Escape hatch: manual two-action pattern | ~15 lines, 0 attrs |
Rule of thumb: start with 7.1. Move to 7.2 or 7.3 only when you need custom UX (spinners, text changes, progress indicators) or loading that is real application state. Within 7.3, reach for the manual two-action pattern only when Async is unavailable — it is not the default any more.
During form submission, the framework automatically:
aria-busy="true" on the form<fieldset> elements inside the form (if present)<form method="POST">
<fieldset>
<input name="title">
<button type="submit">Save</button>
</fieldset>
</form>
<style>
form[aria-busy="true"] fieldset {
opacity: 0.5;
pointer-events: none;
}
</style>
No lvt-* attributes needed, no Go code. The <fieldset> wrapping is the signal. This covers the "grey out the whole form" case. For custom loading UX beyond grey-out, see 7.2 or 7.3.
When you need custom loading UX (spinners, text changes, visual feedback) and are willing to use lvt-* attributes, lvt-el:*:on:pending gives you instant feedback with zero Go code:
<button name="save"
lvt-el:toggleAttr:on:pending="disabled"
lvt-el:addClass:on:pending="opacity-50"
lvt-el:removeClass:on:done="opacity-50">
Save
</button>
// Just the business logic — no loading scaffolding
func (c *Controller) Save(state State, ctx *livetemplate.Context) (State, error) {
time.Sleep(700 * time.Millisecond) // simulate slow work
state.Name = ctx.GetString("Name")
return state, nil
}
The pending state fires instantly on click (before the server even receives the message) and clears on done. This is the most concise option for custom loading UX.
Trade-offs: the pending state is client-only — it does not fan out to peer tabs, does not survive reconnect, and cannot drive server-side logic. The action blocks the event loop for its duration (no other clicks or peer pushes until it returns). For loading that is real application state, use 7.3.
It does survive a server re-render. Since @livetemplate/client v0.18.1 the classes and attributes applied by lvt-el: are client-owned: the morph pass re-applies them after each server patch, so re-rendering the element does not wipe the pending class. You do not need lvt-ignore-attrs to protect them.
See the Client Attributes Reference — Reactive Attributes for the full lvt-el:* pattern.
When loading is real application state — it needs to survive reconnect, fan out to peers, or drive server-side logic — model it on the server and render it with ordinary template conditionals. This keeps everything in Tier 1 (no lvt-* attributes).
livetemplate.Async is the primitive. It runs slow work off the event loop, then re-enters the loop to apply the result to the current state and re-render — one method, no manual goroutine, no dispatch plumbing:
func Async[S any, R any](
ctx *Context,
work func(context.Context) (R, error),
apply func(s S, result R, err error) (S, error),
)
Scope: Async is supported only inside action handlers (e.g. Greet, Save) that run on the per-connection WebSocket event loop. Calling it from Mount(), OnConnect(), dispatched actions, server-initiated actions, upload handlers, or HTTP POST handlers logs a warning and drops the operation — there is no event loop to re-enter. Those cases need the manual two-action pattern below.
Async + {{.lvt.Pending}}When the loading indicator is purely visual, you do not need a Loading field at all. {{.lvt.Pending}} is a framework-provided template variable — true on the render that registered async work, false on every other render (including the completion render):
func (c *Controller) Greet(state State, ctx *livetemplate.Context) (State, error) {
name := strings.TrimSpace(ctx.GetString("name"))
livetemplate.Async(ctx,
func(ctx context.Context) (string, error) {
time.Sleep(700 * time.Millisecond) // simulate slow work
return name, nil
},
func(s State, name string, err error) (State, error) {
s.Name = name
return s, nil
},
)
return state, nil
}
<button name="greet" {{if .lvt.Pending}}disabled{{end}}>
{{if .lvt.Pending}}Loading...{{else}}Greet{{end}}
</button>
No Loading field, no lvt-* attributes, one method — pure Go and standard HTML templates.
{{.lvt.Pending}} has per-render semantics: if another action or a peer dispatch triggers a render on the same connection while the work is still in flight, that render sees Pending=false. For an indicator that must stay visible across interleaved renders, use an explicit field — next section.
Async + a Loading fieldKeep a Loading field when the loading state has to do more than paint the screen — drive a re-entrancy guard, fan out to peers, or survive a reconnect:
type State struct {
Name string
Loading bool
}
func (c *Controller) Greet(state State, ctx *livetemplate.Context) (State, error) {
state.Loading = true
name := strings.TrimSpace(ctx.GetString("name"))
livetemplate.Async(ctx,
func(ctx context.Context) (string, error) {
time.Sleep(700 * time.Millisecond) // simulate slow work
return name, nil
},
func(s State, name string, err error) (State, error) {
s.Name = name
s.Loading = false
return s, nil
},
)
return state, nil // render #1: Loading=true
// render #2 happens when work completes: Loading=false
}
<button name="greet" {{if .Loading}}disabled{{end}}>
{{if .Loading}}Loading...{{else}}Greet{{end}}
</button>
The key guarantees:
apply sees the current state, not a snapshot — any actions that ran during the async window are visible. Mutate only the fields you own.work must not touch session state — only its own inputs. It receives a context.Context tied to the connection's lifetime.session := ctx.Session() before defining apply and call session.TriggerAction() from inside it; ctx itself is not in scope there.work completes, the goroutine is cancelled and apply never runs.If the indicator must survive a reconnect, the field has to carry the lvt:"persist" tag. Unpersisted fields reset to their zero value on reconnect, so a guard reading an untagged Loading never fires on the new connection.
See the Async API reference for the full contract.
Before Async, server-owned loading required two methods: one to set Loading=true and spawn the work, another to apply the result. You still need that shape when:
Async is illegal — Mount, OnConnect, upload handlers, HTTP POST handlers (see Scope above);Async is one-shot — one work, one apply — so a ticker, a progress bar, or a streaming job still spawns a goroutine that calls TriggerAction per update;OnConnect.type State struct {
Name string
Loading bool
}
// Action 1: set Loading=true, spawn the slow work off the event loop
func (c *Controller) Greet(state State, ctx *livetemplate.Context) (State, error) {
if state.Loading {
return state, nil // re-entrancy guard: ignore clicks while loading
}
session := ctx.Session()
if session == nil {
return state, nil // nil check: no session on initial HTTP render
}
name := strings.TrimSpace(ctx.GetString("name"))
state.Loading = true
go func() {
time.Sleep(700 * time.Millisecond) // simulate slow work
_ = session.TriggerAction("finishGreet", map[string]any{"name": name})
}()
return state, nil // render #1: spinner on
}
// Action 2: clear Loading, apply the result
func (c *Controller) FinishGreet(state State, ctx *livetemplate.Context) (State, error) {
state.Name = ctx.GetString("name")
state.Loading = false
return state, nil // render #2: spinner off
}
Why two methods? LiveTemplate's event loop processes one action per render cycle. To show a spinner and later clear it, the slow work must return early (render #1: spinner on) and re-enter the event loop via Session.TriggerAction (render #2: spinner off). Async does exactly this for you — the two renders are the same, only the bookkeeping moves into the framework.
Four things to get right (all of them handled for you by Async):
if state.Loading { return }): prevents double-clicks from spawning duplicate goroutines. The {{if .Loading}}disabled{{end}} on the button is the UI-level guard; the Go check is the server-level backup.if session == nil): ctx.Session() returns nil during initial HTTP renders (before WebSocket connects). The goroutine + TriggerAction pattern requires a live session.TriggerAction returns ErrSessionDisconnected — the goroutine exits cleanly.FinishGreet method name must match the string passed to TriggerAction. A typo silently fails (the action dispatches but no method handles it).Use standard onsubmit for confirmation dialogs:
<form method="POST" onsubmit="return confirm('Delete this item?')">
<input type="hidden" name="id" value="{{.ID}}">
<button name="delete">Delete</button>
</form>
Use native <details> and <summary>:
<details>
<summary>Advanced Options</summary>
<div>
<input name="advanced_setting" value="{{.AdvancedSetting}}">
</div>
</details>
Works without JavaScript. Keyboard accessible by default.
Add a Change() method to your controller to enable live updates as the user types — no lvt-* attributes needed:
<form method="POST">
<input name="Name" value="{{.Name}}">
<div class="preview">Hello, {{.Name}}!</div>
<button type="submit">Save</button>
</form>
func (c *Controller) Change(state State, ctx *livetemplate.Context) (State, error) {
if ctx.Has("Name") { state.Name = ctx.GetString("Name") }
return state, nil
}
What happens: The server detects the Change() method and sends capabilities: ["change"] in the initial render. The client auto-wires debounced input events (300ms default) on form fields with dynamic values. The preview updates live as the user types. If no Change() method exists, the form is submit-only.
Override the default debounce per input with lvt-mod:debounce:
<input name="Name" value="{{.Name}}" lvt-mod:debounce="500">
A todo app using Tier 1 only (zero lvt-* attributes):
<h1>Todos ({{.ActiveCount}} remaining)</h1>
<form method="POST">
<input type="text" name="Title" required minlength="1" placeholder="New todo...">
{{if .lvt.HasError "title"}}
<span class="error">{{.lvt.Error "title"}}</span>
{{end}}
<button type="submit">Add</button>
</form>
<ul>
{{range .FilteredItems}}
<li data-key="{{.ID}}">
<form method="POST">
<input type="hidden" name="id" value="{{.ID}}">
<span>{{.Title}}</span>
<button name="toggle">{{if .Done}}Undo{{else}}Done{{end}}</button>
<button name="delete">Delete</button>
</form>
</li>
{{end}}
</ul>
<form name="filter" method="POST">
<button name="filter" value="all">All</button>
<button name="filter" value="active">Active</button>
<button name="filter" value="done">Done</button>
</form>
func (c *TodoController) Submit(state TodoState, ctx *livetemplate.Context) (TodoState, error) {
if err := ctx.ValidateForm(); err != nil {
return state, err
}
state.Items = append(state.Items, Todo{ID: uuid.New(), Title: ctx.GetString("Title")})
return state, nil
}
func (c *TodoController) Toggle(state TodoState, ctx *livetemplate.Context) (TodoState, error) {
// toggle by ctx.GetString("id")
return state, nil
}
func (c *TodoController) Delete(state TodoState, ctx *livetemplate.Context) (TodoState, error) {
// delete by ctx.GetString("id")
return state, nil
}
func (c *TodoController) Filter(state TodoState, ctx *livetemplate.Context) (TodoState, error) {
state.ActiveFilter = ctx.GetString("filter")
return state, nil
}
LiveTemplate supports three transport layers that degrade gracefully: WebSocket → fetch (HTTP) → no-JS (POST + page reload). All Tier 1 features (Sections 1-11) work across all three layers. This is controlled by the ProgressiveEnhancement config flag (default: true).
When JavaScript is unavailable, forms submit as standard HTML POST requests. The server uses the Post-Redirect-Get (PRG) pattern:
<form method="POST">lvt-flash cookie (10-second max-age, consumed immediately on the next GET)This is why all Tier 1 examples use <form method="POST"> — they work without JavaScript by design.
Security note: When using no-JS POST mode, implement CSRF protection (e.g.,
gorilla/csrfor equivalent CSRF middleware). The JS transport modes send theOriginheader that the server validates on WebSocket upgrade and fetch; plain HTML form POST does not carry the same protection.
When JavaScript is available but WebSocket is not connected, the JS client intercepts form submissions and sends them via fetch(). The server responds with a JSON tree update, and the client patches the DOM. No page reload occurs.
This transport is also used as the automatic fallback when a WebSocket connection disconnects.
Full bidirectional communication. Actions are sent as WebSocket messages, and the server can push updates at any time. Server push (Session.TriggerAction()) and peer fan-out (ctx.Publish() on a subscribed topic) reach peer tabs only in this mode.
The server determines the client's transport from the HTTP request:
| Signal | Transport | Response |
|---|---|---|
| WebSocket upgrade header | WebSocket | Upgrade to WebSocket, send JSON trees |
Accept: application/json |
fetch (JS client) | JSON tree update |
Standard browser Accept: text/html |
No JS | Full HTML page (PRG pattern for POST) |
For a complete feature-by-transport breakdown, see the Transport Compatibility table in the reference doc.
tmpl := livetemplate.New("app",
livetemplate.WithProgressiveEnhancement(false),
)
When disabled, POST requests from non-JS browsers return JSON instead of HTML. Only disable this if all clients have JavaScript.
lvt-* AttributesUse lvt-* attributes only when standard HTML cannot express the behavior. For the complete attribute reference, see the Client Attributes Reference.
For interactions outside the form submit lifecycle — hover effects, focus/blur tracking:
<!-- Server-rendered tooltip on hover (use CSS :hover for static tooltips instead) -->
<div lvt-on:mouseenter="showTooltip" lvt-on:mouseleave="hideTooltip">
{{.Label}}
{{if .TooltipVisible}}<span class="tooltip">{{.TooltipText}}</span>{{end}}
</div>
Prefer Tier 1 when possible: For buttons that trigger actions, use
<form>+<button name="action" value="save">instead oflvt-on:click. See Section 2.
See Client Attributes Reference — Event Bindings for the full list of lvt-on:{event} bindings.
HTML has no mechanism for debounce or throttle. Debounce waits until the user stops (ideal for typing). Throttle limits frequency (ideal for scroll/resize). Both are essential for search inputs and scroll handlers:
<!-- Wait 300ms after user stops typing -->
<input lvt-on:input="search" lvt-mod:debounce="300" name="query" placeholder="Search...">
<!-- Fire scroll handler at most once per 100ms -->
<div lvt-on:window:scroll="loadMore" lvt-mod:throttle="100">...</div>
See Client Attributes Reference — Rate Limiting for details.
Filter events by key and listen at the window level for global shortcuts:
<!-- Submit search on Enter key only -->
<input lvt-on:keydown="submitSearch" lvt-key="Enter" name="query">
<!-- Global Escape key to close modal -->
<div lvt-on:window:keydown="closeModal" lvt-key="Escape">
Modal content...
</div>
See Client Attributes Reference — Keyboard Events for valid key values.
Declarative DOM mutations tied to the action lifecycle (pending, success, error, done):
<!-- Button with loading state -->
<button name="save"
lvt-el:toggleAttr:on:pending="disabled"
lvt-el:addClass:on:pending="opacity-50"
lvt-el:removeClass:on:done="opacity-50">
Save
</button>
<!-- Reset form after successful submission -->
<form method="POST" lvt-el:reset:on:success>
<input name="title" placeholder="New todo">
<button type="submit">Add</button>
</form>
Available reactive actions: lvt-el:addClass:on:*, lvt-el:removeClass:on:*, lvt-el:toggleClass:on:*, lvt-el:setAttr:on:*, lvt-el:toggleAttr:on:*, lvt-el:reset:on:*.
See Client Attributes Reference — Reactive Attributes for the full pattern.
Declarative UI behaviors for scroll management, visual feedback, and animations. Configuration uses CSS custom properties (defaults provided by livetemplate.css):
<!-- Chat messages: auto-scroll to bottom, stick if user is near bottom -->
<div lvt-fx:scroll="bottom-sticky" style="--lvt-scroll-threshold: 100px" class="chat-messages">
{{range .Messages}}
<div>{{.Text}}</div>
{{end}}
</div>
<!-- Preserve scroll position across updates (e.g., search results) -->
<div lvt-fx:scroll="preserve" class="results">
{{range .Results}}
<div>{{.Title}}</div>
{{end}}
</div>
<!-- Highlight updated items -->
<div lvt-fx:highlight="flash">{{.UpdatedContent}}</div>
<!-- Fade in new content -->
<div lvt-fx:animate="fade">{{.NewContent}}</div>
Scroll modes: bottom (always scroll to bottom), bottom-sticky (scroll only if user is near bottom), top (scroll to top), preserve (maintain current scroll position across updates).
CSS custom properties: --lvt-scroll-behavior, --lvt-scroll-threshold, --lvt-highlight-color, --lvt-highlight-duration, --lvt-animate-duration.
Directives also support lifecycle and DOM event triggers via :on: syntax. Without :on:, the directive fires on every DOM content update. With :on:{state}, it fires on a lifecycle state. With :on:{event}, it fires on a native DOM event:
<!-- Highlight on successful save action -->
<div lvt-fx:highlight:on:save:success="flash">Save confirmed</div>
<!-- Highlight on click (DOM event trigger, no server round-trip) -->
<div lvt-fx:highlight:on:click="flash">Click to highlight</div>
See Client Attributes Reference — Directives for all scroll, highlight, and animation options.
A search interface combining debounced input, loading states, keyboard shortcuts, and scroll preservation.
Change()vslvt-input: UseChange()(Section 10) when you want generic live-update on all form inputs — nolvt-*needed. Uselvt-inputwhen you need per-element control: a specific action name, custom debounce, or only some inputs triggering server calls.
<h1>Search</h1>
<!-- Global Escape key clears the search -->
<div lvt-on:window:keydown="clearSearch" lvt-key="Escape">
<!-- lvt-on:input fires directly without a form — Tier 2 event binding -->
<input name="Query" value="{{.Query}}"
lvt-on:input="search" lvt-mod:debounce="300"
lvt-el:addClass:on:pending="border-blue-500"
lvt-el:removeClass:on:done="border-blue-500"
placeholder="Type to search...">
<form method="POST">
<button name="clearSearch"
lvt-el:toggleAttr:on:pending="disabled">
Clear
</button>
</form>
<div class="results" lvt-fx:scroll="preserve">
{{if .Query}}
<p>{{len .Results}} results for "{{.Query}}"</p>
{{end}}
{{range .Results}}
<div data-key="{{.ID}}" lvt-fx:animate="fade">
<h3>{{.Title}}</h3>
<p>{{.Summary}}</p>
</div>
{{end}}
</div>
</div>
type SearchController struct {
DB *sql.DB
}
type SearchState struct {
Query string
Results []Result
}
func (c *SearchController) Search(state SearchState, ctx *livetemplate.Context) (SearchState, error) {
state.Query = ctx.GetString("Query")
if state.Query == "" {
state.Results = nil
return state, nil
}
results, err := c.DB.Search(state.Query)
if err != nil {
return state, err
}
state.Results = results
return state, nil
}
func (c *SearchController) ClearSearch(state SearchState, ctx *livetemplate.Context) (SearchState, error) {
state.Query = ""
state.Results = nil
return state, nil
}
See also: Progressive Complexity Reference for a quick-lookup table of HTML attributes and their framework behaviors, Client Attributes Reference for the complete lvt-* attribute listing, and Ephemeral Components Guide for implementing client-side toast/alert patterns.