Ephemeral components are UI elements that appear briefly, deliver information, and then disappear — toasts, banners, alerts, and confirmation flashes. They have no meaningful persistent state from the server's perspective.
This guide explains why these components should live entirely on the client and how to implement that pattern correctly.
When a toast or alert is rendered in a LiveTemplate server template, it becomes part of the diff tree. That creates several problems:
lvt:"persist" tag), the DOM still carries stale toast elements between updates until the server explicitly clears themThe right model: the server signals the client; the client creates and manages the DOM.
The server renders a single hidden <span> with a data-pending attribute containing JSON:
<span
data-toast-trigger="notifications"
data-pending='[{"id":"1","title":"Saved","body":"Item saved.","type":"success","dismissible":true,"dismissMS":5000}]'
hidden
aria-hidden="true"
></span>
After each DOM update, a client directive reads data-pending, creates the toast DOM, and handles auto-dismiss and click-outside — no server round-trip needed.
HTML escaping safety:
html/templateautomatically escapes the JSON inside thedata-pendingattribute. Combined with the single-quote wrapping of the attribute value, entity-escaped characters in the JSON (e.g.,&,<) are decoded correctly by the browser's HTML parser beforeJSON.parsesees the string. No manual escaping is needed.
The github.com/livetemplate/lvt/components/toast package provides a Container that queues messages and serializes them on demand.
Add a *toast.Container to your state struct. Do not add lvt:"persist" — the container is non-serializable and must be re-initialized from initComponents:
type AppState struct {
// persistent fields ...
// Component state (non-persistent, re-initialized each connection)
Toasts *toast.Container
}
Note on
AssertPureState: If your tests uselvt/testing.AssertPureState[T](t)to verify state contains no dependency types,*toast.Containerwill need to be excluded. Component containers are not external dependencies — they hold transient UI data, not connections or handles. UseAssertPureStatewith theIgnoreFieldsoption, or structure your state so component fields live in a separate struct that is not checked.
Initialize the container wherever non-persistent fields may be nil — Mount (first connection), OnConnect (reconnection), and Sync (cross-connection state sync):
func initComponents(state AppState) AppState {
if state.Toasts == nil {
state.Toasts = toast.New("notifications",
toast.WithPosition(toast.TopRight),
toast.WithMaxVisible(3),
)
state.Toasts.SetStyled(false)
}
return state
}
func (c *Controller) Mount(state AppState, ctx *livetemplate.Context) (AppState, error) {
state = initComponents(state)
return state, nil
}
func (c *Controller) OnConnect(state AppState, ctx *livetemplate.Context) (AppState, error) {
state = initComponents(state)
return state, nil
}
func (c *Controller) Sync(state AppState, ctx *livetemplate.Context) (AppState, error) {
state = initComponents(state)
return state, nil
}
All three hooks must call initComponents because non-persistent fields (like *toast.Container) are nil after deserialization. Missing any hook causes a nil-pointer panic on that code path.
Call the convenience helpers from any action handler:
func (c *Controller) Save(state AppState, ctx *livetemplate.Context) (AppState, error) {
// ... business logic ...
state.Toasts.AddSuccess("Saved", "Your changes have been saved.")
return state, nil
}
Available helpers: AddInfo, AddSuccess, AddWarning, AddError.
Use the provided component template to render the trigger span:
{{ template "lvt:toast:container:v1" .Toasts }}
This renders a hidden <span data-toast-trigger="..." data-pending='...'> when messages are queued. The pending JSON is drained during rendering. Because LiveTemplate evaluates dynamic template expressions twice per action (once for HTML output, once for the diff tree), TakePendingJSON() must be explicitly idempotent — the first call drains and caches; the second returns the cached value.
The handleToastDirectives function in client/dom/directives.ts is called by the framework after every DOM update. It reads data-pending, creates toast DOM elements, and schedules auto-dismiss.
A per-element property (__lvtPendingProcessed) prevents the same batch from being shown twice if the directive fires multiple times before the DOM is patched again:
// Already handled by handleToastDirectives in directives.ts
// No custom JS needed in your app.
Click-outside dismissal is set up once at connect time via setupToastClickOutside().
Both functions are wired automatically — no action needed in application code.
The client directive creates DOM elements (the toast stack, toast items) that are not in the server-rendered HTML. This matters because LiveTemplate uses a morphdom-style diff that removes DOM nodes not present in the server tree on every update.
Two consequences:
The toast stack ([data-lvt-toast-stack]) is removed on each server update. The directive re-creates it every time there are pending messages — no problem.
In LiveTemplate's DOM update strategy, CSS dynamically injected into <head> via JS is also removed on each server update, because the injected <style> element is not in the server-rendered <head> and the diffing algorithm removes it.
The solution: CSS for client-managed elements belongs in the component template, not in the consuming app. The container.tmpl template already renders a <style> block alongside the trigger span:
{{define "lvt:toast:container:v1"}}
{{- $c := . -}}
{{- $pending := $c.TakePendingJSON -}}
<style>
[data-lvt-toast-stack] { position: fixed; top: 1rem; right: 1rem; ... }
[data-lvt-toast-item] { ... }
[data-lvt-toast-item] > button { width: auto; background: transparent; ... }
</style>
<span
data-toast-trigger="{{$c.ID}}"
{{- if $pending}} data-pending='{{$pending}}'{{end}}
hidden aria-hidden="true"
></span>
{{end}}
Because container.tmpl is included in every server render (it's called from the page template), the diffing algorithm sees the <style> on every response and keeps it. The consuming app template needs no CSS for the component.
Source:
github.com/livetemplate/lvt/components/toast— the fullContainerAPI, message helpers, and template.
Follow the same pattern for any short-lived UI element (alert banners, confirmation flashes, etc.):
Add the component to state as a non-persistent field. Provide TakePendingJSON()-style drain method that is idempotent across LiveTemplate's double-evaluation:
// In your component:
func (c *MyComponent) TakePendingJSON() string {
if c.hasNewData {
b, err := json.Marshal(c.data)
if err != nil {
// Log the error; return empty so the client directive is a no-op.
log.Printf("mycomponent: failed to marshal pending data: %v", err)
c.data = nil
c.hasNewData = false
return ""
}
c.renderedJSON = string(b)
c.data = nil
c.hasNewData = false
return c.renderedJSON
}
result := c.renderedJSON
c.renderedJSON = ""
return result
}
Note: Always handle the
json.Marshalerror. Silently discarding it (e.g.,b, _ := json.Marshal(...)) can hide bugs — for example, a field with an unsupported type will produce empty output with no indication of failure.
The three-call contract:
"" — the data has been consumed.Include a <style> block for the client-managed DOM in the component template — not in the consuming app. Since the template is called on every server render, morphdom keeps the <style> element and the CSS is always in the page.
{{define "myapp:alert:v1"}}
{{- $c := . -}}
{{- $pending := $c.TakePendingJSON -}}
<style>
[data-lvt-alert-stack] { position: fixed; bottom: 1rem; left: 1rem; ... }
[data-lvt-alert-item] { ... }
</style>
<span
data-alert-trigger="{{$c.ID}}"
{{- if $pending}} data-pending='{{$pending}}'{{end}}
hidden aria-hidden="true"
></span>
{{end}}
dom/directives.tsexport function handleAlertDirectives(rootElement: Element): void {
rootElement.querySelectorAll<HTMLElement>("[data-alert-trigger]").forEach((trigger) => {
const pending = trigger.getAttribute("data-pending");
if (!pending) return;
if ((trigger as any).__lvtAlertProcessed === pending) return;
(trigger as any).__lvtAlertProcessed = pending;
let messages: AlertMessage[];
try { messages = JSON.parse(pending); } catch { return; }
messages.forEach((msg) => {
// Create and insert alert DOM
});
});
}
livetemplate-client.tsImport and call from updateDOM():
import { handleAlertDirectives } from "./dom/directives";
// In updateDOM():
handleAlertDirectives(element);
| Anti-pattern | Why it fails |
|---|---|
| Render full toast HTML in the template | Unnecessary diff traffic; server must be involved in dismissal |
Call TakePendingJSON() only once |
LiveTemplate double-evaluates; the diff tree sees empty string |
Store toast messages with lvt:"persist" |
Toasts re-appear after page reload; stale state in session store |
| Write custom JS in the app template | Breaks the framework's progressive-complexity contract |
See also: Progressive Complexity Guide for the broader Tier 1/Tier 2 model.