I recently bumped into the Elm Architecture while exploring the Bubble Tea library for building a terminal user interface (TUI) in Go for one of my side projects. I found it to be a really interesting pattern for structuring applications, so I wanted to share a quick overview of how it works and how to implement it in a Go program using Bubble Tea.
What is the Elm Architecture?
The Elm Architecture is a pattern for building well-architected interactive programs or user interfaces. It was originally developed for the Elm programming language, but has since been adopted by other languages and frameworks, suchs as the Bubble Tea library for Go.
How it works
The Elm Architecture is based on three main concepts: Model, View, and Update (often abbreviated as MVU).
- Model: the state of the application, represented as a struct with fields for each piece of state
- View: a function that takes the current model and returns a string representation of the UI
- Update: a function that takes the current model and a message, and returns an updated model
The Elm Architecture follows a unidirectional data flow, where messages are sent to the update function, which updates the model, which is then passed to the view function to render the UI. This pattern allows for a clear separation of concerns, making it easier to reason about the state of the application and how it changes over time.
We can call this flow the “event loop”, which is composed by the MVU pattern, along with the concepts of Messages and Commands.
- The program starts and the initial model is created
- The view function is called with the initial model to render the UI
- The program listens for events, such as key presses or window resizes
- When an event occurs, a message is created and sent to the update function
- The update function takes the current model and the message, and returns an updated model
- The view function is called again with the updated model to render the new UI
- The loop continues until the program is exited
Messages
Messages are essentially events that trigger changes in the application state. They can represent user actions (like key presses or mouse clicks), system events (like timers or network responses), or any other event that requires the application to update its state. Messages are typically defined as a set of types or structs, each representing a different kind of event.
Commands
Commands are functions that perform side effects, such as making HTTP requests or connecting to a database. They are used to interact with the outside world, and when they complete, they send a message back to the update function. This allows for a clear separation of concerns, as the update function can focus on updating the model based on messages, while commands handle the side effects.
This diagram illustrates how the event loop works:
graph LR Model[Model] --> View[View] View --> Msg[Msg] Msg --> Update[Update] Update --> SideEffect{Side Effect?} SideEffect -->|No| Model SideEffect -->|Yes| Cmd[Cmd] Cmd --> Msg
Example
To create a Bubble Tea program using the Elm Architecture, follow these steps:
- Import the Bubble Tea package
- Define the model struct to hold the application state
- Create the initial model function to set the starting state
- Implement the view function to render the UI based on the model
- Implement the update function to handle messages and update the model
- Create the main function to start the Bubble Tea program
TIP
For a complete guide on how to build a TUI application with Bubble Tea, I recommend checking out this video.
Here is a quick example of a Bubble Tea program that implements the Elm Architecture:
package main
import (
"fmt"
"os"
tea "github.com/charmbracelet/bubbletea"
)
type model struct {
count int
}
func initialModel() model {
return model{count: 0}
}
func (m model) View() string {
return fmt.Sprintf("Count: %d\nPress + to increment, - to decrement, q to quit.", m.count)
}
func (m model) Update(msg tea.Msg) (model, tea.Cmd) {
switch msg := msg.(type) {
case tea.KeyMsg:
switch msg.String() {
case "q", "ctrl+c":
return m, tea.Quit
case "+":
m.count++
case "-":
m.count--
}
}
return m, nil
}
func main() {
p := tea.NewProgram(initialModel(), tea.WithAltScreen())
if err := p.Start(); err != nil {
fmt.Fprintf(os.Stderr, "Error starting program: %v\n", err)
os.Exit(1)
}
}