Bubbletea Patterns.Skill Rule
Bubbletea implements the Elm Architecture:
Bubbletea Patterns Skill
Version: 1.0 Purpose: Idiomatic Bubbletea patterns learned from Glow, Soft Serve, and community best practices
Overview
Bubbletea implements the Elm Architecture:
- Model - Application state
- Update - State transitions (messages → new model, commands)
- View - Rendering model to terminal
Key: The program sends messages (tea.Msg), Update handles them, returns (newModel, optionalCmd)
Model Architecture
Nested Models Pattern
For non-trivial apps, use nested models with a top-level router:
type Model struct {
// Shared state accessible to all sub-models
common *CommonModel
// Sub-models (each implements tea.Model)
board *board.Model
detail *detail.Model
settings *settings.Model
overlays *overlay.Stack
// Current state for routing
state State
}
type CommonModel struct {
config *config.Config
width int
height int
styles *styles.Styles
program *tea.Program // For sending messages from goroutines
}
Key insight from Glow: Share common state via pointer to avoid duplication across sub-models.
Init: Batch Sub-Model Initialization
func (m Model) Init() tea.Cmd {
return tea.Batch(
m.board.Init(),
m.detail.Init(),
m.settings.Init(),
loadInitialData, // Your custom init command
)
}
Update: Message Routing Pattern
Pass ALL messages to relevant sub-models, not just "active" one:
func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
var cmds []tea.Cmd
// Global handlers first (window size, quit, etc.)
switch msg := msg.(type) {
case tea.WindowSizeMsg:
m.common.width = msg.Width
m.common.height = msg.Height
// Propagate to all sub-models
m.board.SetSize(msg.Width, msg.Height)
m.detail.SetSize(msg.Width, msg.Height)
return m, nil
case tea.KeyMsg:
// Handle quit regardless of state
if msg.String() == "ctrl+c" {
return m, tea.Quit
}
}
// Route to active overlay first (if any)
if m.overlays.Current() != nil {
overlay, cmd := m.overlays.Current().Update(msg)
m.overlays.SetCurrent(overlay)
if cmd != nil {
cmds = append(cmds, cmd)
}
// Overlays may consume the message
if m.overlays.Current() != nil {
return m, tea.Batch(cmds...)
}
}
// Route to current view
switch m.state {
case StateBoard:
newBoard, cmd := m.board.Update(msg)
m.board = newBoard.(*board.Model)
cmds = append(cmds, cmd)
case StateDetail:
newDetail, cmd := m.detail.Update(msg)
m.detail = newDetail.(*detail.Model)
cmds = append(cmds, cmd)
}
return m, tea.Batch(cmds...)
}
Commands
tea.Cmd Types
// Command returning a message
func fetchTasks() tea.Cmd {
return func() tea.Msg {
return TasksLoadedMsg{tasks: getTasks()}
}
}
// Command with timeout
func fetchWithTimeout() tea.Cmd {
return tea.Tick(time.Second, func(t time.Time) tea.Msg {
return TimeoutMsg{}
})
}
// Command from goroutine
func asyncWork() tea.Cmd {
ch := make(chan tea.Msg)
go func() {
defer close(ch)
ch <- WorkDoneMsg{result: doWork()}
}()
return func() tea.Msg {
return <-ch
}
}
// Batch commands
func (m Model) Init() tea.Cmd {
return tea.Batch(
m.board.Init(),
m.detail.Init(),
tea.Tick(time.Second, tickerTick), // Periodic
)
}
Sending Messages from Goroutines
// In goroutine, send via program
func (s *Service) pollState(program *tea.Program) {
for {
time.Sleep(500 * time.Millisecond)
state := s.checkState()
program.Send(StateChangedMsg{state: state})
}
}
// In model Update, handle the message
case StateChangedMsg msg:
// Handle state change
View Pattern
Delegated Views
func (m Model) View() string {
switch m.state {
case StateBoard:
return m.board.View()
case StateDetail:
return m.detail.View()
case StateSettings:
return m.settings.View()
default:
return ""
}
}
Styled Views (Lip Gloss)
var (
// Define styles once (avoid reallocation in View())
baseStyle = lipgloss.NewStyle()
titleStyle = baseStyle.Bold(true).Foreground(lipgloss.Color("205"))
activeStyle = baseStyle.Background(lipgloss.Color("240"))
)
func (m Model) View() string {
return lipgloss.NewStyle().
Width(m.common.width).
Height(m.common.height).
Render(
lipgloss.Place(
m.common.width, m.common.height,
lipgloss.Center, lipgloss.Center,
m.content.View(),
),
)
}
Border Styles
var (
boxStyle = lipgloss.NewStyle().
Border(lipgloss.RoundedBorder()).
BorderForeground(lipgloss.Color("63")).
Padding(1, 2)
)
func (m Model) View() string {
return boxStyle.Render(m.content)
}
State Management
Simple State Enum
type State int
const (
StateBoard State = iota
StateDetail
StateSettings
)
State with Data
type State struct {
view ViewType
selectedItem int
isLoading bool
}
Message Types
Custom Messages
// Define custom message types (avoid collisions)
type TaskSelectedMsg struct {
id string
}
type TasksLoadedMsg struct {
tasks []Task
}
type StateChangedMsg struct {
state SessionState
}
Built-in Messages
| Message | Description |
|----------|-------------|
| tea.InitMsg | Program started (return initial cmd) |
| tea.QuitMsg | Request program exit |
| tea.WindowSizeMsg | Terminal size changed |
| tea.KeyMsg | Key press event |
| tea.MouseMsg | Mouse event (enable with tea.WithMouseCellMotion()) |
| tea.TickMsg | Timer event (from tea.Tick) |
Keyboard Patterns
Key Matching
func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
switch msg := msg.(type) {
case tea.KeyMsg:
switch msg.String() {
case "ctrl+c", "q":
return m, tea.Quit
case "up", "k":
m.cursor--
case "down", "j":
m.cursor++
case "enter":
return m, m.selectCurrent()
}
}
return m, nil
}
Key Types
case tea.KeyMsg:
switch msg.Type {
case tea.KeyRunes:
// Any character key (letters, numbers, etc.)
case tea.KeyEnter, tea.KeySpace:
// Enter or Space
case tea.KeyUp, tea.KeyDown, tea.KeyLeft, tea.KeyRight:
// Arrow keys
case tea.KeyCtrlC, tea.KeyCtrlD:
// Ctrl combinations
}
Alt/Modifer Keys
case tea.KeyMsg:
if msg.Alt {
// Alt+key
}
Component Patterns
Using Bubbles
import (
"github.com/charmbracelet/bubbles/list"
"github.com/charmbracelet/bubbles/textinput"
"github.com/charmbracelet/bubbles/viewport"
)
type Model struct {
list list.Model
input textinput.Model
content viewport.Model
}
func (m Model) Init() tea.Cmd {
m.list = list.New(..., list.NewDefaultDelegate())
m.input = textinput.New()
m.content = viewport.New(0, 0)
return nil
}
Focus Management
type Model struct {
focused focusedComponent
}
type focusedComponent int
const (
focusList focusedComponent = iota
focusInput
)
func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
switch msg := msg.(type) {
case tea.KeyMsg:
if msg.String() == "tab" {
// Cycle focus
m.focused = (m.focused + 1) % 2
}
}
// Update based on focus
switch m.focused {
case focusList:
var cmd tea.Cmd
m.list, cmd = m.list.Update(msg)
return m, cmd
case focusInput:
var cmd tea.Cmd
m.input, cmd = m.input.Update(msg)
return m, cmd
}
}
Performance Patterns
View Memoization
// ❌ BAD: Reallocates styles every render
func (m Model) View() string {
return lipgloss.NewStyle().
Bold(true).
Render("Title")
}
// ✅ GOOD: Styles defined once
var titleStyle = lipgloss.NewStyle().Bold(true)
func (m Model) View() string {
return titleStyle.Render("Title")
}
View Optimization
// ❌ BAD: Renders entire content every frame
func (m Model) View() string {
return m.content
}
// ✅ GOOD: Only render what changed
func (m Model) View() string {
if m.needsRender {
m.needsRender = false
return m.content
}
return "" // Skip rendering
}
Common Patterns
Confirmation Dialog
type ConfirmModel struct {
question string
confirmed bool
}
func (m ConfirmModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
switch msg := msg.(type) {
case tea.KeyMsg:
if msg.String() == "y" {
m.confirmed = true
return m, tea.Quit
}
if msg.String() == "n" || msg.String() == "esc" {
return m, tea.Quit
}
}
return m, nil
}
func (m ConfirmModel) View() string {
return fmt.Sprintf("%s [y/n]", m.question)
}
Loading State
type Model struct {
loading bool
content string
}
func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
switch msg := msg.(type) {
case LoadStartMsg:
m.loading = true
case LoadDoneMsg:
m.loading = false
m.content = msg.data
}
return m, nil
}
func (m Model) View() string {
if m.loading {
return "Loading..."
}
return m.content
}
Error Handling
type Model struct {
err error
}
func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
switch msg := msg.(type) {
case errMsg:
m.err = msg.err
}
return m, nil
}
func (m Model) View() string {
if m.err != nil {
return lipgloss.NewStyle().
Foreground(lipgloss.Color("196")).
Render(fmt.Sprintf("Error: %v", m.err))
}
return m.content
}
Best Practices
- Define styles once as package-level variables (avoid View() reallocation)
- Pass context to goroutines and check for cancellation
- Use tea.Batch to combine multiple commands
- Handle tea.WindowSizeMsg to respond to terminal resizes
- Type switch on messages (not fmt.Sprintf, slower)
- Keep View() pure - no side effects, just render state
- Use bubbles components for lists, inputs, viewports
- Close channels from sender side only
- Use tea.Quit to exit gracefully
- Test with different terminal sizes for responsive layouts
References
- Bubbletea Tutorial
- Bubbletea Examples
- Lip Gloss Documentation
- Bubbles Components
- Glow Source - Production patterns
- Soft Serve Source - Production patterns