Complete guide to error handling in LiveTemplate applications.
Return an error from an action and it travels to the client and renders in the template. You write no serialization code for it.
User submits form
↓
Server: Action method processes request
↓
Validation error occurs
↓
Error returned from action method
↓
LiveTemplate wraps error with metadata
↓
Error sent to client in response
↓
Template re-renders with error data
↓
User sees error messages
Errors in LiveTemplate are returned from action methods on your controller.
func (c *TodoController) Delete(state TodoState, ctx *livetemplate.Context) (TodoState, error) {
id := ctx.GetString("id")
if id == "" {
return state, fmt.Errorf("ID is required")
}
if err := c.DB.DeleteTodo(id); err != nil {
return state, fmt.Errorf("failed to delete todo: %w", err)
}
// Remove from state
state.Items = removeItem(state.Items, id)
return state, nil
}
When an action method returns an error:
lvt:error)LiveTemplate recognizes different error types:
fmt.Errorf(), errors.New()livetemplate.FieldErrorlivetemplate.MultiErrorgo-playground/validatorLiveTemplate integrates with go-playground/validator for field-level validation.
import "github.com/go-playground/validator/v10"
var validate = validator.New()
type TodoInput struct {
Title string `json:"title" validate:"required,min=3,max=100"`
Description string `json:"description" validate:"max=500"`
Priority int `json:"priority" validate:"min=1,max=5"`
}
func (c *TodoController) Add(state TodoState, ctx *livetemplate.Context) (TodoState, error) {
var input TodoInput
// BindAndValidate automatically handles validation errors
if err := ctx.BindAndValidate(&input, validate); err != nil {
return state, err // Errors sent to client with field names
}
// Input is valid, proceed
state.Todos = append(state.Todos, Todo{
Title: input.Title,
Description: input.Description,
Priority: input.Priority,
})
return state, nil
}
Common validation tags:
| Tag | Description | Example |
|---|---|---|
required |
Field must not be empty | validate:"required" |
min |
Minimum value/length | validate:"min=3" |
max |
Maximum value/length | validate:"max=100" |
email |
Valid email format | validate:"email" |
url |
Valid URL format | validate:"url" |
alpha |
Alphabetic characters only | validate:"alpha" |
numeric |
Numeric characters only | validate:"numeric" |
alphanum |
Alphanumeric characters | validate:"alphanum" |
oneof |
Value must be one of | validate:"oneof=red green blue" |
See validator documentation for complete list.
Create field-specific errors manually:
func (c *Controller) Register(state State, ctx *livetemplate.Context) (State, error) {
username := ctx.GetString("username")
// Check if username already exists
if c.usernameExists(username) {
return state, livetemplate.NewFieldError("username",
errors.New("username already taken"))
}
state.Username = username
return state, nil
}
Return multiple field errors at once:
func (c *Controller) Register(state State, ctx *livetemplate.Context) (State, error) {
var errs livetemplate.MultiError
email := ctx.GetString("email")
if !isValidEmail(email) {
errs = append(errs,
livetemplate.NewFieldError("email",
errors.New("invalid email format")))
}
password := ctx.GetString("password")
if len(password) < 8 {
errs = append(errs,
livetemplate.NewFieldError("password",
errors.New("password must be at least 8 characters")))
}
if len(errs) > 0 {
return state, errs
}
return state, c.createUser(email, password)
}
LiveTemplate provides template helpers for displaying errors.
| Helper | Description | Returns |
|---|---|---|
.lvt.HasError "field" |
Check if field has error | bool |
.lvt.Error "field" |
Get error message for field | string |
.lvt.ErrorTag "field" |
Get error in <small> tag (or empty) |
template.HTML |
.lvt.AriaInvalid "field" |
Get aria-invalid="true" if error (or empty) |
template.HTMLAttr |
.lvt.AriaDisabled "field" ... |
Get aria-disabled="true" if any field has error (or empty) |
template.HTMLAttr |
.lvt.Errors |
Get all errors | map[string]string |
<form method="POST">
<label for="email">Email
<input type="email" id="email" name="email" {{.lvt.AriaInvalid "email"}}>
{{.lvt.ErrorTag "email"}}
</label>
<button type="submit">Save</button>
</form>
AriaInvalid outputs aria-invalid="true" when the field has an error, or nothing when it doesn't. ErrorTag renders <small>error message</small> or nothing. Together they replace the verbose {{if .lvt.HasError}}...{{end}} pattern.
Always use AriaInvalid in your templates. It is required for WebSocket (JS) updates, which is the primary LiveTemplate use case. As a safety net, non-JS form submissions also get automatic aria-invalid injection on the HTTP response — but this is a progressive enhancement fallback, not a replacement for the template helper.
AriaDisabled is for related UI elements that should appear disabled because errors exist — not for the errored field itself. A field with a validation error is still interactive (the user must fix it), so applying aria-disabled to it would incorrectly signal that the element cannot be used. It accepts multiple field names and returns aria-disabled="true" if any of them have errors:
<form method="POST">
<input type="email" name="email" {{.lvt.AriaInvalid "email"}}>
{{.lvt.ErrorTag "email"}}
<input type="text" name="name" {{.lvt.AriaInvalid "name"}}>
{{.lvt.ErrorTag "name"}}
<button type="submit" {{.lvt.AriaDisabled "email" "name"}}>Save</button>
</form>
Important: aria-disabled signals a disabled state to assistive technology but does not prevent interaction. To actually block form submission, pair it with the HTML disabled attribute or use JavaScript. LiveTemplate's built-in loading states already handle <fieldset disabled> during submission.
For custom error elements or styling, use the explicit pattern:
<form method="POST">
<div>
<label for="email">Email</label>
<input
type="email"
id="email"
name="email"
{{if .lvt.HasError "email"}}aria-invalid="true"{{end}}>
{{if .lvt.HasError "email"}}
<small class="error">{{.lvt.Error "email"}}</small>
{{end}}
</div>
<button type="submit">Save</button>
</form>
<input
type="text"
name="username"
class="{{if .lvt.HasError "username"}}input-error{{end}}">
With CSS:
.input-error {
border-color: #ef4444;
background-color: #fef2f2;
}
{{if .lvt.Errors}}
<div class="error-summary">
<h4>Please fix the following errors:</h4>
<ul>
{{range $field, $message := .lvt.Errors}}
<li><strong>{{$field}}:</strong> {{$message}}</li>
{{end}}
</ul>
</div>
{{end}}
<form method="POST">
{{if .lvt.Errors}}
<div class="alert alert-error">
{{range .lvt.Errors}}
<p>{{.}}</p>
{{end}}
</div>
{{end}}
<!-- Form fields -->
<button name="create" type="submit">Create</button>
</form>
Handle errors in JavaScript using form lifecycle events.
const form = document.querySelector('form');
form.addEventListener('lvt:error', (e) => {
console.log('Validation failed');
console.log('Errors:', e.detail.errors);
// e.detail contains:
// {
// action: "save",
// errors: {
// "email": "invalid email format",
// "password": "password too short"
// },
// meta: {
// success: false
// }
// }
});
form.addEventListener('lvt:error', (e) => {
const errorCount = Object.keys(e.detail.errors).length;
showNotification(`Please fix ${errorCount} error(s)`, 'error');
});
form.addEventListener('lvt:error', (e) => {
const firstErrorField = Object.keys(e.detail.errors)[0];
const input = form.querySelector(`[name="${firstErrorField}"]`);
if (input) {
input.focus();
}
});
document.querySelectorAll('input').forEach(input => {
input.addEventListener('input', () => {
// Clear error styling when user starts typing
input.classList.remove('input-error');
const errorMsg = input.parentElement.querySelector('.error');
if (errorMsg) {
errorMsg.style.display = 'none';
}
});
});
LiveTemplate provides specific error types for different scenarios.
Represents an error for a specific form field.
type FieldError struct {
Field string
Message string
}
func (e FieldError) Error() string {
return fmt.Sprintf("%s: %s", e.Field, e.Message)
}
Usage:
return livetemplate.NewFieldError("email", errors.New("email already exists"))
Collection of field errors.
type MultiError []FieldError
func (m MultiError) Error() string {
// Returns concatenated error messages
}
Usage:
var errs livetemplate.MultiError
errs = append(errs, livetemplate.NewFieldError("email", errors.New("invalid")))
errs = append(errs, livetemplate.NewFieldError("password", errors.New("too short")))
return errs
Automatically created by BindAndValidate() when using go-playground/validator.
// Automatically converts validator errors to MultiError
if err := ctx.BindAndValidate(&input, validate); err != nil {
return err // Returns MultiError with field names
}
Flash messages are page-level notifications that don't affect form success/failure. Unlike field errors, flash messages are used for success confirmations, warnings, and informational messages.
| Aspect | Field Errors | Flash Messages |
|---|---|---|
| Purpose | Validation failures | User notifications |
| Source | Action method errors | Manual ctx.SetFlash() |
| Affects Success | Yes | No |
| Example | "Email is invalid" | "Profile updated!" |
Use ctx.SetFlash(key, message) in your action methods. By default, flash messages persist on a WebSocket connection until you call ctx.ClearFlash(key) — see Flash Message Lifecycle below.
func (c *ProfileController) Update(state ProfileState, ctx *livetemplate.Context) (ProfileState, error) {
var input ProfileInput
if err := ctx.BindAndValidate(&input, validate); err != nil {
return state, err
}
if err := c.DB.UpdateProfile(input); err != nil {
return state, fmt.Errorf("failed to update profile: %w", err)
}
// persists until ctx.ClearFlash("success") is called
ctx.SetFlash("success", "Profile updated successfully!")
state.Profile = input.ToProfile()
return state, nil
}
FlashExpiryPass livetemplate.FlashExpiry(d) for transient feedback that should disappear on its own:
ctx.SetFlash("success", "Saved!", livetemplate.FlashExpiry(5*time.Second))
The message is pruned on the next render that walks flash state after the duration elapses — there is no background timer, so the user must trigger a render (action, peer-fan-out publish, or scan-loop refresh) to see it disappear. A duration of 0 or less disables auto-expiry, behaving as if FlashExpiry were not provided.
FlashExpiry has no observable effect on HTTP connections — HTTP flash is already one-shot per request.
Call ctx.ClearFlash(key) when the user has acknowledged the message (e.g., after a follow-up action or navigation):
func (c *MyController) Acknowledge(state MyState, ctx *livetemplate.Context) (MyState, error) {
ctx.ClearFlash("error")
return state, nil
}
You can set flash messages alongside validation errors:
func (c *TodoController) BulkDelete(state TodoState, ctx *livetemplate.Context) (TodoState, error) {
var input struct {
IDs []string `json:"ids"`
}
if err := ctx.Bind(&input); err != nil {
return state, err
}
var errs livetemplate.MultiError
deleted := 0
for _, id := range input.IDs {
if err := c.DB.DeleteTodo(id); err != nil {
errs = append(errs, livetemplate.NewFieldError(id, err.Error()))
} else {
deleted++
}
}
// Report partial success via flash
if deleted > 0 {
ctx.SetFlash("info", fmt.Sprintf("Deleted %d items", deleted))
}
if len(errs) > 0 {
return state, errs
}
return state, nil
}
| Helper | Description | Returns |
|---|---|---|
.lvt.HasFlash "key" |
Check if flash exists | bool |
.lvt.Flash "key" |
Get flash message | string |
.lvt.HasAnyFlash |
Check if any flash exists | bool |
.lvt.AllFlash |
Get all flash messages | map[string]string |
.lvt.FlashTag "key" |
Get flash in <output> tag with ARIA role (or empty) |
template.HTML |
Concise flash rendering with FlashTag:
<!-- Instead of verbose {{if .lvt.HasFlash}}...{{end}} blocks: -->
{{.lvt.FlashTag "success"}}
{{.lvt.FlashTag "error"}}
{{.lvt.FlashTag "warning"}}
{{.lvt.FlashTag "info"}}
FlashTag renders an <output> element with role="status" for all keys except "error" which uses role="alert". The data-flash attribute identifies the flash type for CSS styling. Returns empty when no flash message exists for the key.
Success notification:
{{if .lvt.HasFlash "success"}}
<div class="alert alert-success">
{{.lvt.Flash "success"}}
</div>
{{end}}
Multiple flash types:
{{if .lvt.HasFlash "success"}}
<div class="alert alert-success">{{.lvt.Flash "success"}}</div>
{{end}}
{{if .lvt.HasFlash "error"}}
<div class="alert alert-danger">{{.lvt.Flash "error"}}</div>
{{end}}
{{if .lvt.HasFlash "warning"}}
<div class="alert alert-warning">{{.lvt.Flash "warning"}}</div>
{{end}}
{{if .lvt.HasFlash "info"}}
<div class="alert alert-info">{{.lvt.Flash "info"}}</div>
{{end}}
Display all flash messages:
{{range $key, $msg := .lvt.AllFlash}}
<div class="alert alert-{{$key}}">{{$msg}}</div>
{{end}}
| Key | Purpose | Example |
|---|---|---|
success |
Operation completed | "Profile saved!" |
error |
Non-field error | "Connection failed" |
warning |
Caution message | "Session expiring soon" |
info |
Informational | "New features available" |
Flash follows a persist-until-cleared lifecycle. On a WebSocket connection, a flash message remains visible across re-renders until you remove it explicitly with ctx.ClearFlash(key) or attach an expiry with livetemplate.FlashExpiry(d). This matches the Phoenix LiveView model where flash is a separate namespace from assigns: background updates (TriggerAction, scan-loop refreshes) that change state fields do not touch flash messages.
| Step | Action |
|---|---|
| Set | Action handler calls ctx.SetFlash("success", "Saved!") |
| Render | Template displays flash via {{.lvt.Flash "success"}} |
| Persist | Flash survives subsequent re-renders (no auto-clear) |
| Clear | ctx.ClearFlash("success") or FlashExpiry removes it |
Key behaviors:
ClearFlash, because the per-request connection state is GC'd after the handler returnsctx.Redirect() does not survive the redirect — no flash cookie is written before the redirect response, so the message is lost. Use a session-backed mechanism (or a query param) if you need flash to survive an HTTP redirectResponseMetadata.Success (only field errors do)Multi-tab behavior: If a user has multiple tabs open (same session group):
ctx.Subscribe(ctx.SelfTopic()) (state is shared)In earlier releases, flash was automatically cleared after each render (one-shot). Flash now persists on WebSocket connections until ClearFlash is explicitly called or FlashExpiry elapses. Existing handlers that relied on auto-clear and don't call ClearFlash will accumulate flash across re-renders.
To avoid accumulation when migrating from v0.8, choose one of:
// Option A: persist and clear explicitly in the follow-up handler
ctx.SetFlash("success", "Saved!")
// ...later, in the action that acknowledges the message:
ctx.ClearFlash("success")
// Option B: auto-expire after a fixed duration
ctx.SetFlash("success", "Saved!", livetemplate.FlashExpiry(5*time.Second))
// Option C: dismiss client-side (e.g., on click); the dismiss handler
// calls ctx.ClearFlash("success") — same effect as Option A, triggered
// by user gesture instead of a follow-up server action
❌ Bad:
return errors.New("invalid input")
✅ Good:
return livetemplate.NewFieldError("email",
errors.New("email must be a valid email address"))
func (c *Controller) Add(state State, ctx *livetemplate.Context) (State, error) {
// Validate input first
var input TodoInput
if err := ctx.BindAndValidate(&input, validate); err != nil {
return state, err
}
// Then perform business logic
if err := c.saveTodo(input); err != nil {
return state, fmt.Errorf("failed to save: %w", err)
}
state.Todos = append(state.Todos, input.ToTodo())
return state, nil
}
✅ Good UX:
<input name="email">
{{if .lvt.HasError "email"}}
<small class="error">{{.lvt.Error "email"}}</small>
{{end}}
<input
name="email"
{{if .lvt.HasError "email"}}
aria-invalid="true"
aria-describedby="email-error"
{{end}}>
{{if .lvt.HasError "email"}}
<span id="email-error" role="alert">
{{.lvt.Error "email"}}
</span>
{{end}}
LiveTemplate automatically preserves form data on error. No special handling needed.
For errors that don't belong to a specific field:
// Return general error
return errors.New("database connection failed")
Display in template:
{{if .lvt.Errors}}
{{if .lvt.Error ""}}
<div class="alert alert-error">
{{.lvt.Error ""}}
</div>
{{end}}
{{end}}
Server:
type SignupInput struct {
Username string `json:"username" validate:"required,min=3,max=20,alphanum"`
Email string `json:"email" validate:"required,email"`
Password string `json:"password" validate:"required,min=8"`
}
type AuthController struct {
DB *sql.DB
}
func (c *AuthController) Signup(state AuthState, ctx *livetemplate.Context) (AuthState, error) {
var input SignupInput
// Validate input
if err := ctx.BindAndValidate(&input, validate); err != nil {
return state, err
}
// Check if username exists
if c.usernameExists(input.Username) {
return state, livetemplate.NewFieldError("username",
errors.New("username already taken"))
}
// Check if email exists
if c.emailExists(input.Email) {
return state, livetemplate.NewFieldError("email",
errors.New("email already registered"))
}
// Create user
if err := c.createUser(input); err != nil {
return state, fmt.Errorf("failed to create account: %w", err)
}
state.IsSignedUp = true
return state, nil
}
Template:
<form method="POST">
<h2>Sign Up</h2>
{{if .lvt.Errors}}
<div class="alert alert-error">
<p>Please fix the errors below</p>
</div>
{{end}}
<div class="form-group">
<label for="username">Username</label>
<input
type="text"
id="username"
name="username"
class="{{if .lvt.HasError "username"}}input-error{{end}}"
{{if .lvt.HasError "username"}}aria-invalid="true"{{end}}>
{{if .lvt.HasError "username"}}
<small class="error">{{.lvt.Error "username"}}</small>
{{end}}
</div>
<div class="form-group">
<label for="email">Email</label>
<input
type="email"
id="email"
name="email"
class="{{if .lvt.HasError "email"}}input-error{{end}}"
{{if .lvt.HasError "email"}}aria-invalid="true"{{end}}>
{{if .lvt.HasError "email"}}
<small class="error">{{.lvt.Error "email"}}</small>
{{end}}
</div>
<div class="form-group">
<label for="password">Password</label>
<input
type="password"
id="password"
name="password"
class="{{if .lvt.HasError "password"}}input-error{{end}}"
{{if .lvt.HasError "password"}}aria-invalid="true"{{end}}>
{{if .lvt.HasError "password"}}
<small class="error">{{.lvt.Error "password"}}</small>
{{end}}
<small class="help">Must be at least 8 characters</small>
</div>
<button name="signup" type="submit" class="btn-primary">Sign Up</button>
</form>
JavaScript:
const form = document.querySelector('form');
form.addEventListener('lvt:error', (e) => {
// Focus first invalid field
const firstField = Object.keys(e.detail.errors)[0];
const input = form.querySelector(`[name="${firstField}"]`);
if (input) {
input.focus();
}
// Show notification
showNotification('Please fix the errors in the form', 'error');
});
form.addEventListener('lvt:success', (e) => {
showNotification('Account created successfully!', 'success');
// Redirect or clear form
});