RSSAmplifier

Roland Rodriguez · Jan 5, 2026

Treating UI Regions as Independent Actors Makes Terminal State Manageable

0
Sign in to vote or save

Roland · rodriguez.today

Terminal UIs feel stuck because we think about them wrong. We treat the screen as a canvas to paint on, managing coordinates and redraw logic ourselves. Web frameworks showed us a better way for browser UIs: compose independent components that own their state and coordinate through messages. But there is an even older set of ideas that maps surprisingly well to terminal interfaces: the actor model, battle-tested in Erlang for building fault-tolerant distributed systems.

What happens when you treat each UI region as an independent actor? The short answer: complex state becomes manageable, and the constraints that make terminals challenging actually enable better solutions.

Why Actors for UI?

Context. Modern web frameworks demonstrated that UIs become easier to reason about when we think of them as compositions of independent components. Each component owns its state, decides when to render, and communicates through well-defined channels. This mental model replaced older approaches where global state and imperative updates created tangled dependencies.

The challenge. Terminal applications face specific problems that make this component-based thinking even more valuable. Multiple regions update independently: a header showing connection status, a main content area displaying data, a footer tracking keystrokes. Each region has different update triggers and different state. Managing this with shared global state becomes difficult quickly. The traditional approach, treating the screen as a canvas you paint on, forces you to coordinate all these regions yourself.

What I did. I chose to apply actor model patterns from distributed systems to this terminal UI problem. The core idea: treat each screen region as an independent actor with private state, message-based coordination, and supervised lifecycle management. I chose actors partly because I wanted to exercise acton-reactive, a framework I built, but also because actors offered built-in isolation and supervision, properties I would need to manually implement with other patterns.

What I found. Complex terminal state becomes manageable through three key properties. First, treating UI regions as independent actors eliminates the shared mutable state that makes terminal applications hard to maintain. Second, distributed systems patterns from Erlang translate surprisingly well to UI problems. Isolation, message-passing, and supervision solve exactly the challenges complex terminal interfaces create. Third, design constraints enable better solutions than maximum flexibility. The terminal's limitations force architectural decisions that actually simplify development.

The Actor Model for UI

The actor model provides exactly the properties complex terminal applications need: isolation, message-passing, and supervision as built-in features rather than patterns you implement yourself.

Isolation gives you truly private state. Each component's state lives inside its actor. No component can accidentally mutate another's state. No shared mutable globals. No debugging sessions trying to figure out who changed what and when. Each actor receives messages, updates its own state, and decides whether to re-render. The boundaries are enforced by the architecture, not by discipline.

Message passing makes data flow explicit. Changes flow through channels you define. When the terminal resizes, the screen manager broadcasts a resize message. When business logic updates a counter, it broadcasts a state change. Components subscribe to the messages they care about. The flow is visible in the code, and you can trace exactly what triggered what. This is not just organizational nicety. It is debuggability built into the architecture.

Supervision provides graceful failure handling. This is where Erlang's "let it crash" philosophy gets interesting for UIs. If a component fails (maybe it received malformed data or hit an edge case) its supervisor handles it. Restart the component. Log the error. Continue running. The footer crashing does not take down your entire application. This matters more for long-running terminal applications like dashboards or monitoring tools that need to stay up despite individual component failures.

Natural concurrency emerges from the architecture. Components process their messages independently. No coordination locks. No "wait, is this thread-safe?" questions. The actor runtime handles message delivery and isolation. You focus on what each component does, not how to synchronize them.

Architecture: How It Works in Practice

The architecture is simpler than it might sound. The screen manager acts as parent supervisor, owning the physical terminal. Child actors own logical regions. They know their bounds and content, but do not write directly to the screen. When something changes, they send a render request to the parent.

Here is the conceptual structure:

                    Terminal Events (resize, keypress)
                               │
                               ▼
              ┌─────────────────────────────────────┐
              │        ScreenManager (Parent)       │
              │   - owns the physical terminal      │
              │   - supervises child components     │
              │   - handles cursor positioning      │
              └───────────────┬─────────────────────┘
                              │ supervise()
              ┌───────────────┼───────────────┐
              ▼               ▼               ▼
        ┌──────────┐   ┌──────────┐   ┌──────────┐
        │  Header  │   │ Content  │   │  Footer  │
        │  Actor   │   │  Actor   │   │  Actor   │
        └────┬─────┘   └────┬─────┘   └────┬─────┘
             │              │              │
             └──────────────┴──────────────┘
                        RenderRequest
                            │
                            ▼
                   ScreenManager writes
                   only that region

The parent actor is the single source of truth for actually writing characters. Child actors own logical regions. When a child's state changes, it constructs its rendered content and sends a render request: "here is my content, here is where it goes." The parent coordinates: it moves the cursor to the right position, writes the content, handles the next request. Only the region that changed gets redrawn.

I chose this parent-child separation for a specific reason: it enforces single-writer semantics for the terminal while allowing each region to manage its own complexity independently. The header actor does not know about the footer's coordinates. The content actor does not care whether the header exists. Each actor has a well-defined responsibility. The screen manager handles physical terminal operations and component supervision. Child actors handle their own rendering logic and state management. The boundaries are clear.

There is one more actor not shown in the diagram: an AppState actor that owns the application's business logic (the counter value, connection status, and other domain state). This actor lives outside the UI tree entirely. When something changes in business logic, AppState broadcasts typed messages (CounterChanged, StatusChanged) that UI components subscribe to. This separation means the screen manager knows nothing about counters or status. It only knows about regions and rendering. Business logic and UI concerns stay cleanly decoupled.

When a terminal resize happens, the screen manager receives the event and broadcasts it. Each component recalculates whether its region changed. If so, it sends a new render request. The coordination happens through messages, not through shared state or callbacks. This message-based coordination requires three specific patterns working together.

Three Patterns That Make It Work

The isolation and message-passing properties from the previous section work in practice through three specific message patterns. Each pattern solves a distinct coordination problem, and together they make the architecture coherent.

Pattern 1: Broadcast for system events. When the terminal resizes, every component needs to know. The screen manager receives the resize event and broadcasts it through a central broker. Components subscribe to system events. They each recalculate their regions and request repaints if needed. This broadcast pattern ensures system-wide events reach all interested components without the screen manager needing to know which components exist or care. I chose broadcast for system events because the alternative, maintaining a list of all components and notifying each directly, couples the screen manager to every component.

Pattern 2: Direct messaging for coordination. Render requests do not go through broadcast. They go directly from child to parent. This keeps message flow clear: system events fan out, render requests converge back. The asymmetry is intentional. Broadcasts are for one-to-many notifications. Direct messages are for specific coordination between two actors.

Pattern 3: Topic-based subscriptions for business logic. Application state changes broadcast typed messages. A counter changes? Broadcast CounterChanged(42). The footer subscribes to CounterChanged; the header does not. Status updates? Different message type, different subscribers. The broker routes messages only to interested components, so there is no wasted delivery and no local filtering needed.

Here is what that looks like in practice:

// Subscribe only to relevant message types
component.handle().subscribe::<CounterChanged>().await;
component.handle().subscribe::<PageChanged>().await;
// Counter updates only re-render on the main page
.mutate_on::<CounterChanged>(move |actor, ctx| {
    actor.model.counter = ctx.message().0;
    // Skip re-render if we're on a different page
    if actor.model.current_page != Page::Main {
        return Reply::ready();
    }
    let content = actor.model.render(&actor.model.region);
    Reply::pending(async move {
        parent.send(RenderRequest { region, content }).await;
    })
})

The component subscribes only to messages it cares about. The broker handles routing. Each component tracks which page is active, so updates that do not affect the current view get ignored. No wasted rendering cycles. This selective subscription combined with local state checks means components stay efficient without the screen manager needing to orchestrate everything.

What Changes: Development Experience

These patterns change how development feels, not just how code organizes.

Adding new components becomes trivial. Create an actor. Subscribe to the events you care about. Implement your region calculation and render logic. Done. You do not modify existing components or touch the screen manager's supervision logic. The supervision tree automatically includes the new component. Want a new status indicator in the header? Add a child actor that subscribes to status messages. The architecture composes.

Testing becomes straightforward. Each component is independently testable. Send it mock messages, verify it produces the right render requests. No need to set up an actual terminal or mock complex global state. The message-based design makes test doubles simple: create a fake parent that collects render requests instead of writing to a terminal. Assert on the requests. The isolation that helps with production code helps equally with tests.

Debugging gets easier. Enable message logging and you see exactly what triggered what. "The footer repainted because it received CounterChanged(5) at 14:23:01.234." The causality is explicit in the logs. No guessing about callback orders or wondering which part of global state changed. The message flow tells the story.

Scaling has a clear path. Multiple terminals? Spawn multiple screen manager actors, each supervising their own component tree. The pattern composes naturally. Need to split a complex component? Extract part of its state into a child actor and have them coordinate through messages. The supervision tree grows organically as complexity increases.

The Honest Trade-offs

This is not the right approach for everything.

For a simple progress bar or a one-off CLI tool, this approach adds unnecessary complexity. The conceptual overhead does not pay for itself. You are better off just drawing directly to the terminal. The actor model shines when you have genuinely independent regions with different update triggers and long-running applications where maintainability matters.

There is a learning curve. If you have not worked with actor systems before, the message-passing mindset takes time to internalize. It is a different way of thinking about program structure. You are not calling methods on objects. You are sending messages to independent entities. The shift is subtle but real. For teams unfamiliar with actors, expect initial friction.

There is more structure than immediate-mode drawing. You define message types and handlers rather than just calling draw_text(x, y, content). But this is where framework choice matters. acton-reactive was designed specifically to minimize ceremony. Subscriptions are one-liners. Message handlers use ergonomic macros. The framework handles supervision, lifecycle, and broker coordination so you focus on your component logic. The structure you write is the structure that matters for your application, not framework scaffolding.

But for complex, long-running terminal applications (dashboards, multi-pane editors, monitoring tools, anything where regions update independently based on different data sources) the investment pays off. The architecture stays maintainable as complexity grows. Adding features does not require understanding the entire codebase. The message boundaries make the system comprehensible.

Design Lessons: What This Teaches Beyond TUIs

This experiment in applying actor patterns to terminal UIs revealed several transferable principles about approaching novel design problems, specifically how to borrow proven patterns from one domain and adapt them to another.

Lesson 1: Borrow patterns across domains. The actor model was not designed for UIs. It was designed for building fault-tolerant distributed systems. But the core problems overlap: independent entities with private state, coordinated through messages, with graceful failure handling. The specific domain matters less than the underlying patterns. When facing a novel problem, look for analogous problems that have battle-tested solutions. The patterns often transfer even when the context seems completely different.

Lesson 2: Constraints enable better solutions than flexibility. The terminal's limitations (no z-ordering, no overlapping regions, constrained coordinate system) force architectural decisions that actually simplify development. Each actor owns a rectangular region. No overlap. No complex layout algorithms. These constraints make the region-based actor model natural.

In my implementation, I defined just three breakpoints: Small (under 50 columns), Medium (50-80 columns), and Large (80+ columns). Each component adapts its rendering based on these thresholds. The header shows one row on small screens, three rows with a separator on large screens. The footer switches between compact shortcuts and a full two-row layout. These simple breakpoints replaced what could have been a complex responsive layout system. The constraint of "pick three sizes and handle them explicitly" turned out to be easier to reason about than continuous flexibility. Maximum flexibility often leads to maximum complexity. Strategic constraints guide you toward simpler solutions.

Lesson 3: Make data flow explicit. Message-passing is not just an implementation detail. It makes causality visible. When you can see that CounterChanged triggered a footer repaint, you understand the system. Implicit data flow through shared state or callbacks hides these relationships. The explicitness costs some verbosity but pays back in debuggability and maintainability. Make important relationships visible in the code.

Lesson 4: Isolation enables composition. Components that cannot interfere with each other compose safely. Add a new actor, and you know it cannot corrupt existing actors' state. Remove an actor, and you know exactly what breaks: anything that was sending it messages or receiving from it. The message boundaries create natural seams in the system. Isolation is not just about preventing bugs. It is about making the system comprehensible as it grows.

Implementation and Next Steps

The key insight is not about any particular framework or language. It is that treating UI regions as independent, message-driven entities, borrowing decades of distributed systems wisdom, produces maintainable, efficient terminal applications. The patterns transfer to other domains where you need to coordinate independent components with complex state.

I implemented this concept using acton-reactive, a reactive actor framework for Rust that provides supervision strategies, broker-based pub/sub, and lifecycle hooks. The demo application demonstrates the concept through dogfooding: press d and it displays its own architecture diagram through the same message-passing system the diagram documents.

If you are building terminal applications that go beyond simple tools, these patterns are worth considering. The architecture scales naturally from simple to complex, and the constraints that seem limiting often guide you toward better solutions.


The acton-reactive crate provides the actor primitives used in this experiment. Check out the documentation if you are interested in bringing these patterns to Rust, or just want to see the patterns in more detail.

Read the original on rodriguez.today

Comments

Nothing yet. Say the first thing.

    Sign in to join the conversation.