Overview
Middleware in Bubble Tea allows you to intercept and transform messages before they reach your Update function. This enables powerful patterns like validation, logging, access control, and message transformation.
WithFilter Option
The WithFilter option provides middleware capabilities by supplying an event filter that processes messages before Bubble Tea handles them:
func WithFilter ( filter func ( Model , Msg ) Msg ) ProgramOption
How It Works
Message arrives : User input, commands, or other events generate a message
Filter intercepts : Your filter function receives the model and message
Transform or block : Return a modified message, different message, or nil
Continue processing : The returned message (if not nil) goes to Update
From tea.go:735-741:
case msg := <- p . msgs :
msg = p . translateInputEvent ( msg )
// Filter messages.
if p . filter != nil {
msg = p . filter ( model , msg )
}
if msg == nil {
continue // Message was blocked
}
Returning nil from your filter function blocks the message entirely - it won’t reach your Update method.
Basic Usage
Blocking Quit Messages
Prevent users from quitting when there are unsaved changes:
package main
import (
" fmt "
" os "
tea " github.com/charmbracelet/bubbletea "
)
type model struct {
content string
hasChanges bool
}
func ( m model ) Init () tea . Cmd {
return nil
}
func ( m model ) Update ( msg tea . Msg ) ( tea . Model , tea . Cmd ) {
switch msg := msg .( type ) {
case tea . KeyMsg :
switch msg . String () {
case "ctrl+c" :
return m , tea . Quit
default :
m . content += msg . String ()
m . hasChanges = true
}
}
return m , nil
}
func ( m model ) View () tea . View {
status := "No changes"
if m . hasChanges {
status = "Unsaved changes!"
}
content := fmt . Sprintf ( " %s \n\n Content: %s \n\n Press Ctrl+C to quit" ,
status , m . content )
return tea . NewView ( content )
}
// Filter function blocks quit attempts when there are unsaved changes
func filter ( m tea . Model , msg tea . Msg ) tea . Msg {
// Type assert to access our model fields
model := m .( model )
// Check if this is a quit message
if _ , ok := msg .( tea . QuitMsg ); ok {
// Block quit if there are unsaved changes
if model . hasChanges {
return nil // Block the message
}
}
return msg // Allow the message through
}
func main () {
p := tea . NewProgram (
model {},
tea . WithFilter ( filter ),
)
if _ , err := p . Run (); err != nil {
fmt . Fprintf ( os . Stderr , "Error: %v \n " , err )
os . Exit ( 1 )
}
}
Message Filtering Patterns
1. Message Blocking
Block messages from reaching your update function:
func blockingFilter ( m tea . Model , msg tea . Msg ) tea . Msg {
model := m .( myModel )
// Block all input when loading
if model . loading {
switch msg .( type ) {
case tea . KeyMsg :
return nil // Block keyboard input
case tea . MouseMsg :
return nil // Block mouse input
}
}
return msg
}
Transform messages before they reach your update function:
func transformFilter ( m tea . Model , msg tea . Msg ) tea . Msg {
// Convert Enter key to a custom command
if keyMsg , ok := msg .( tea . KeyMsg ); ok {
if keyMsg . String () == "enter" {
return submitMsg {} // Custom message type
}
}
return msg
}
type submitMsg struct {}
3. Message Logging
Log all messages for debugging:
import " log "
func loggingFilter ( m tea . Model , msg tea . Msg ) tea . Msg {
log . Printf ( "Message: %T %+v " , msg , msg )
return msg // Pass through unchanged
}
4. Rate Limiting
Limit the frequency of certain messages:
import (
" time "
tea " github.com/charmbracelet/bubbletea "
)
type rateLimitFilter struct {
lastUpdate time . Time
minDelay time . Duration
}
func ( rl * rateLimitFilter ) filter ( m tea . Model , msg tea . Msg ) tea . Msg {
// Rate limit specific message types
if _ , ok := msg .( updateMsg ); ok {
now := time . Now ()
if now . Sub ( rl . lastUpdate ) < rl . minDelay {
return nil // Block - too frequent
}
rl . lastUpdate = now
}
return msg
}
func main () {
rl := & rateLimitFilter { minDelay : 100 * time . Millisecond }
p := tea . NewProgram (
model {},
tea . WithFilter ( rl . filter ),
)
p . Run ()
}
5. Access Control
Implement permission-based message filtering:
type permissions struct {
canEdit bool
canDelete bool
}
func accessControlFilter ( perms permissions ) func ( tea . Model , tea . Msg ) tea . Msg {
return func ( m tea . Model , msg tea . Msg ) tea . Msg {
switch msg := msg .( type ) {
case editMsg :
if ! perms . canEdit {
return unauthorizedMsg { "edit" }
}
case deleteMsg :
if ! perms . canDelete {
return unauthorizedMsg { "delete" }
}
}
return msg
}
}
type editMsg struct {}
type deleteMsg struct {}
type unauthorizedMsg struct { action string }
Advanced Patterns
Composing Multiple Filters
Chain multiple filters together:
func composeFilters ( filters ... func ( tea . Model , tea . Msg ) tea . Msg ) func ( tea . Model , tea . Msg ) tea . Msg {
return func ( m tea . Model , msg tea . Msg ) tea . Msg {
for _ , filter := range filters {
msg = filter ( m , msg )
if msg == nil {
return nil // Short-circuit if blocked
}
}
return msg
}
}
func main () {
combinedFilter := composeFilters (
loggingFilter ,
accessControlFilter ( permissions { canEdit : true }),
rateLimitFilter ,
)
p := tea . NewProgram (
model {},
tea . WithFilter ( combinedFilter ),
)
p . Run ()
}
Conditional Filtering
Apply different filtering logic based on application state:
func conditionalFilter ( m tea . Model , msg tea . Msg ) tea . Msg {
model := m .( myModel )
switch model . mode {
case editMode :
return editModeFilter ( model , msg )
case viewMode :
return viewModeFilter ( model , msg )
case debugMode :
// In debug mode, log everything
log . Printf ( "Debug: %T %+v " , msg , msg )
return msg
}
return msg
}
Message Enrichment
Add context or metadata to messages:
import " time "
type enrichedMsg struct {
original tea . Msg
timestamp time . Time
userID string
}
func enrichmentFilter ( userID string ) func ( tea . Model , tea . Msg ) tea . Msg {
return func ( m tea . Model , msg tea . Msg ) tea . Msg {
// Don't re-enrich already enriched messages
if _ , ok := msg .( enrichedMsg ); ok {
return msg
}
return enrichedMsg {
original : msg ,
timestamp : time . Now (),
userID : userID ,
}
}
}
Real-World Examples
Confirmation Dialog for Destructive Actions
type model struct {
confirmDelete bool
deleteTarget string
}
func confirmationFilter ( m tea . Model , msg tea . Msg ) tea . Msg {
model := m .( model )
if deleteMsg , ok := msg .( deleteRequestMsg ); ok {
if ! model . confirmDelete {
// Block the delete and show confirmation dialog
return showConfirmationMsg { deleteMsg . target }
}
}
return msg
}
type deleteRequestMsg struct { target string }
type showConfirmationMsg struct { target string }
Audit Logging
import (
" log "
" time "
)
func auditFilter ( m tea . Model , msg tea . Msg ) tea . Msg {
// Log security-sensitive messages
switch msg := msg .( type ) {
case loginMsg :
log . Printf ( "AUDIT: Login attempt by %s at %v " , msg . username , time . Now ())
case deleteMsg :
log . Printf ( "AUDIT: Delete operation on %s at %v " , msg . target , time . Now ())
case permissionChangeMsg :
log . Printf ( "AUDIT: Permission changed for %s at %v " , msg . user , time . Now ())
}
return msg
}
func validationFilter ( m tea . Model , msg tea . Msg ) tea . Msg {
if keyMsg , ok := msg .( tea . KeyMsg ); ok {
model := m .( myModel )
// Validate input based on current field
if model . activeField == "email" {
// Only allow valid email characters
if ! isValidEmailChar ( keyMsg . String ()) {
return nil // Block invalid input
}
}
if model . activeField == "phone" {
// Only allow digits and formatting characters
if ! isValidPhoneChar ( keyMsg . String ()) {
return nil
}
}
}
return msg
}
Best Practices
Keep Filters Pure Filters should be pure functions without side effects (except logging)
Fail Safe When in doubt, allow the message through rather than blocking it
Document Blocking Clearly document when and why messages are blocked
Avoid Heavy Logic Keep filters fast - they run on every message
Filters run on every message. Keep them fast and avoid expensive operations.
Filter Function Signature
From options.go:104-137:
// WithFilter supplies an event filter that will be invoked before Bubble Tea
// processes a tea.Msg. The event filter can return any tea.Msg which will then
// get handled by Bubble Tea instead of the original event. If the event filter
// returns nil, the event will be ignored and Bubble Tea will not process it.
func WithFilter ( filter func ( Model , Msg ) Msg ) ProgramOption {
return func ( p * Program ) {
p . filter = filter
}
}
Parameters:
Model: The current model (read-only access)
Msg: The message to filter
Returns:
The message to process (can be transformed)
nil to block the message
You have read-only access to the model in filters. To modify the model, return a custom message that your Update function handles.
Testing Filters
func TestFilter ( t * testing . T ) {
m := model { hasChanges : true }
// Test that quit is blocked with unsaved changes
result := filter ( m , tea . QuitMsg {})
if result != nil {
t . Error ( "Expected quit to be blocked" )
}
// Test that quit is allowed without changes
m . hasChanges = false
result = filter ( m , tea . QuitMsg {})
if result == nil {
t . Error ( "Expected quit to be allowed" )
}
}