How to Use bubbletea for Terminal UIs in Go

Web
Bubbletea is a Go framework that builds terminal user interfaces using the Model-View-Update (MVU) pattern, where you define a model, handle events in an update function, and render the view.

How to Use bubbletea for Terminal UIs in Go

You built a CLI tool that parses JSON and prints a table. It works fine until a user asks for a live dashboard. Or a menu where they can navigate with arrow keys. Or a progress bar that updates without flooding the terminal with newlines. Raw terminal programming means wrestling with ANSI escape codes, cursor positioning, and input buffering. Go gives you a better path. The bubbletea framework turns your terminal into a predictable event loop. You describe the state, handle the events, and return a string. The framework handles the rest.

The MVU loop in plain words

bubbletea follows the Model-View-Update pattern. The name comes from Elm, a functional frontend language, but the idea translates cleanly to Go. You define a struct to hold your state. That is the model. You write a function that takes an event and returns a new model plus an optional command. That is the update step. You write a function that turns the model into a string. That is the view. The framework runs a tight loop: it waits for a message, passes it to your update function, takes the returned model, runs your view function, clears the terminal buffer, and prints the new string. The cycle repeats until you tell it to stop.

Think of it like a turntable. The platter spins at a fixed speed. You drop a needle on a record. The groove dictates the sound. You never touch the speaker directly. You only change the record. The framework handles the needle, the motor, and the audio output. In bubbletea, the record is your model. The needle is the Update function. The speaker is the terminal. You never write escape sequences to move the cursor. You only return the string that represents the current state.

The framework expects three methods on your model type. Init runs once at startup. Update processes every incoming event. View renders the current state. Go interfaces are implicit. You do not declare that your struct implements tea.Model. You just write the three methods with the correct signatures. The compiler checks the contract automatically. If you miss a method or get the return types wrong, the build fails immediately.

State flows in one direction. Events mutate the model. The model dictates the view. You never call View from Update. You never mutate the terminal from View. Keep the data flow unidirectional and the UI stays predictable.

A minimal counter

Here is the simplest working program: a counter that responds to arrow keys and quits on q.

package main

import (
	"fmt"
	tea "github.com/charmbracelet/bubbletea"
)

// model holds the current count.
type model struct {
	count int
}

// Init runs once when the program starts.
func (m model) Init() tea.Cmd {
	return nil // no background work needed yet
}

// Update handles incoming events and returns the next state.
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 // signal the framework to exit
		case "up", " ":
			m.count++ // increment on up arrow or space
		case "down":
			m.count-- // decrement on down arrow
		}
	}
	return m, nil // return unchanged model and no new commands
}

// View renders the current state as a string.
func (m model) View() string {
	return fmt.Sprintf("Count: %d\n(q to quit, up/down to change)", m.count)
}

func main() {
	p := tea.NewProgram(model{}) // initialize with zero-value model
	if _, err := p.Run(); err != nil {
		fmt.Printf("Error: %v\n", err)
	}
}

When you run this, bubbletea takes over the terminal. It switches to raw mode so it can read keystrokes instantly without waiting for Enter. It calls Init once. Since Init returns nil, the loop immediately waits for input. You press the up arrow. The framework wraps the keypress in a tea.KeyMsg and hands it to Update. Your switch statement matches "up", increments count, and returns the modified model. The framework calls View, gets the formatted string, clears the screen, and prints it. The loop repeats. When you press q, Update returns tea.Quit. The framework restores the terminal to its original state, flushes any pending output, and exits cleanly.

Go conventions shape how you write this. The receiver name is m because it matches the type name model. Keep it short. Go style favors one or two letters for receivers. Public names start with a capital letter. Private start lowercase. bubbletea expects your model to implement the tea.Model interface, which requires Init, Update, and View. The compiler enforces this automatically. If you miss a method, you get cannot use model literal as tea.Model value in argument: model does not implement tea.Model (missing Update method). Fix the signature and the type check passes.

Never mutate the terminal directly. Return strings. Trust the loop.

Handling async work with commands

Real programs rarely just count. They poll APIs, watch files, or run timers. bubbletea handles async work through commands. A command is just a function that returns a message. You return a command from Init or Update, and the framework runs it in the background. When it finishes, it sends the returned message back into your Update function.

Here is a timer that tracks elapsed time and updates every second.

package main

import (
	"fmt"
	"time"
	tea "github.com/charmbracelet/bubbletea"
)

// tickMsg carries the timestamp from the timer.
type tickMsg time.Time

// model tracks when the program started.
type model struct {
	startedAt time.Time
}

// Init starts the periodic timer.
func (m model) Init() tea.Cmd {
	return tickCmd() // schedule the first tick
}

// tickCmd returns a command that fires after one second.
func tickCmd() tea.Cmd {
	return tea.Tick(time.Second, func(t time.Time) tea.Msg {
		return tickMsg(t) // wrap the time in our custom message type
	})
}

// Update processes keypresses and timer ticks.
func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
	switch msg := msg.(type) {
	case tea.KeyMsg:
		if msg.String() == "q" || msg.String() == "ctrl+c" {
			return m, tea.Quit
		}
	case tickMsg:
		// timer fired, state doesn't change but we need to keep ticking
	}
	return m, tickCmd() // schedule the next tick
}

// View calculates and displays the elapsed duration.
func (m model) View() string {
	elapsed := time.Since(m.startedAt)
	return fmt.Sprintf("Running for: %v\n(q to quit)", elapsed)
}

func main() {
	p := tea.NewProgram(model{startedAt: time.Now()})
	if _, err := p.Run(); err != nil {
		fmt.Printf("Error: %v\n", err)
	}
}

Commands decouple side effects from state transitions. You never call http.Get or time.Sleep inside Update. You return a tea.Cmd that does the work. The framework runs it in a separate goroutine. When the command finishes, it returns a tea.Msg. The framework queues that message and feeds it back into Update. This keeps the main event loop responsive.

Custom message types are just structs or type aliases. tickMsg time.Time creates a new type that satisfies tea.Msg. The type switch in Update routes messages to the correct handler. If you return a command that produces a message you never handle, the framework silently ignores it. The UI stays frozen on the last frame. Always handle every message type your commands produce.

Go's error handling convention applies here too. p.Run() returns an error. Check it. If you ignore it with _, _ = p.Run(), you lose visibility into terminal restoration failures or signal interruptions. The verbose if err != nil pattern is intentional. It makes failure paths obvious. Wrap errors with fmt.Errorf("failed to run tea program: %w", err) when you need context.

Commands are fire-and-forget. Schedule them and let the loop handle the rest.

Pitfalls and runtime behavior

The event loop runs on a single goroutine. Blocking inside Update or View freezes the entire UI. If you need to fetch data, return a command instead of calling http.Get directly. Commands run concurrently, but you still need to be careful about goroutine leaks. A command that waits on a channel forever will keep the program alive even after you call tea.Quit. Always provide a cancellation path or a timeout. Use context.WithTimeout inside your commands and pass the context to blocking calls.

Type switches in Update are strict. If you forget to handle a message type, the switch falls through and returns the unchanged model. The compiler won't warn you about unhandled cases. If you accidentally return a command that returns the wrong message type, you get a runtime panic when the framework tries to cast it. The framework expects tea.Msg interfaces. Mismatched types surface as interface conversion: tea.Msg is customMsg, not expectedMsg.

Terminal restoration can fail if you panic. Wrap your main function in a recovery handler or use tea.WithAltScreen() carefully. The framework catches panics and prints a stack trace, but the cursor might end up hidden or the colors might stay inverted. Use defer to restore state if you run custom terminal code outside the loop.

Go's formatting convention matters here. Run gofmt on every file. Do not argue about indentation or brace placement. The tool decides. Most editors run it on save. Consistent formatting keeps the focus on logic, not style.

The worst goroutine bug is the one that never logs. Always attach a context or a done channel to long-running commands.

When to reach for bubbletea

Use bubbletea when you need an interactive terminal dashboard, a wizard-style menu, or a progress indicator that updates in place. Use raw os.Stdin and fmt when you only need simple prompts and single-line output. Use a full terminal UI library like tview when you need prebuilt widgets like graphs, tables, and draggable panels. Use a web UI when your users need rich media, complex layouts, or cross-platform consistency beyond the terminal. Use plain sequential code when you don't need concurrency: the simplest thing that works is usually the right thing.

Where to go next