Complete guide to error handling in LiveTemplate applications.
- Overview
- Server-Side Errors
- Validation Errors
- Template Error Display
- Client-Side Error Handling
- Error Types
- Flash Messages
- Best Practices
- Examples
LiveTemplate provides a comprehensive error handling system that automatically propagates validation errors from the server to the client and displays them in templates.
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:
- The error is automatically sent to the client
- Template re-renders with error data available
- Form lifecycle events fire (
lvt:error) - State changes are not persisted
LiveTemplate recognizes different error types:
- Simple errors -
fmt.Errorf(),errors.New() - Field errors -
livetemplate.FieldError - Multiple field errors -
livetemplate.MultiError - Validation errors - From
go-playground/validator
LiveTemplate 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 errsAutomatically 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:
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)
}
// Set success flash message
ctx.SetFlash("success", "Profile updated successfully!")
state.Profile = input.ToProfile()
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 messages follow a "show once" pattern:
- Set: Action handler calls
ctx.SetFlash("success", "Saved!") - Render: Template displays flash via
{{.lvt.Flash "success"}} - Clear: Flash is automatically cleared after the response is sent
Key behaviors:
- Flash messages are per-connection, not shared across browser tabs
- Flash is cleared after each action response (show once pattern)
- Flash does NOT survive page refresh or WebSocket reconnects (not persisted to session)
- Flash messages don't affect
ResponseMetadata.Success(only field errors do)
Multi-tab behavior: If a user has multiple tabs open (same session group):
- Tab 1 triggers action → sets flash → Tab 1 sees flash
- Tab 2 does NOT see Tab 1's flash (flash is per-connection)
- State changes ARE broadcast to Tab 2 (state is shared)
❌ 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
});- Client Attributes Reference - Form lifecycle events
- Go API Reference - Error types API
- go-playground/validator - Validation tags
- Template Support Matrix - Template syntax