Application Architecture

Build robust TUI applications with Tea patterns.

Core Concepts

This section is incomplete. Check the source files.

Thread and Ractor Safety

The Strategic Context

Ruby 4.0 introduces Ractorsโ€”true parallel actors that forbid shared mutable state. Code that passes mutable objects between threads crashes in a Ractor world.

Tea prepares you today. The runtime enforces Ractor-shareability on every Model and Message now, using standard threads. Pass a mutable object, and it raises an error immediately. Write Ractor-safe code today; upgrade to Ruby 4.0 without changes tomorrow.

Enforce immutability rules before you strictly need them, and the migration is invisible.

The Problem

Rubyโ€™s Ractor model prevents data races by forbidding shared mutable state. Mutable objects cause runtime errors:

RatatuiRuby::Error::Invariant: Model is not Ractor-shareable.

The Solution

Use Ractor.make_shareable. It recursively freezes everything:

Ractor.make_shareable(model.with(text: "#{model.text}#{char}"))

For constants, wrap INITIAL:

INITIAL = Ractor.make_shareable(
  Model.new(text: "", running: false, chunks: [])
)

For collection updates:

new_chunks = Ractor.make_shareable([*model.chunks, new_item])

The Lightweight Alternative

When you know exactly whatโ€™s mutable, .freeze is shorter:

[model.with(text: "#{model.text}#{char}".freeze), nil]

Foot-Guns

frozen_string_literal Only Affects Literals

The magic comment freezes strings that appear directly in source code. Computed strings are mutable.

# frozen_string_literal: true

"literal"        # frozen โœ“
"#{var}"         # mutable โœ—
str.chop         # mutable โœ—
str + other      # mutable โœ—

Data.define Needs Shareable Values

Data.define creates frozen instances. The instance is Ractor-shareable only when all its values are shareable.

Quick Reference

Pattern Code
Make anything shareable Ractor.make_shareable(obj)
Freeze a string str.freeze
INITIAL constant Ractor.make_shareable(Model.new(...))
Array update Ractor.make_shareable([*old, new])

Debugging

See this error?

RatatuiRuby::Error::Invariant: Model is not Ractor-shareable.

Wrap the returned model with Ractor.make_shareable.

Modals and Command Result Routing

Modals capture keyboard input. They overlay the main UI and intercept keypresses until dismissed. But async commands keep running in the background. When their results arrive, they look like any other message.

Itโ€™s tempting to intercept all messages when the modal is active. This swallows those command results.

The Scenario

  1. User presses โ€œuโ€ to fetch uptime. The runtime dispatches an async command.

  2. User presses โ€œcโ€ to open a modal dialog.

  3. The uptime command completes. It sends [:network, :uptime, { stdout:, ... }].

  4. The modal intercept sees the modal is active. It routes that message to the modal.

  5. The modal ignores it.

  6. The uptime panel never updates.

The Fix

Route command results before modal interception. Modals intercept user input, not async results.

# Wrong: modal intercepts everything
UPDATE = lambda do |message, model|
  if Modal.active?(model.modal)
    # Swallows command results
    return Modal::UPDATE.call(message, model.modal)
  end

  case message
  in [:network, *rest]
    # Never reached while modal is open
  end
end

# Correct: route command results first
UPDATE = lambda do |message, model|
  # 1. Route async command results (always)
  case message
  in [:network, *rest]
    return [model.with(network: new_network), command]
  in [:stats, *rest]
    return [model.with(stats: new_stats), command]
  else
    nil
  end

  # 2. Modal intercepts user input
  if Modal.active?(model.modal)
    return Modal::UPDATE.call(message, model.modal)
  end

  # 3. Handle other input
  case message
  in _ if message.q? then Command.exit
  end
end

The Router DSL

Tea::Router handles this correctly. Routes declared with route :prefix, to: ChildModule process before keymap handlers. Command results flow through even when guards block keyboard input.

module Dashboard
  include Tea::Router

  route :stats, to: StatsPanel
  route :network, to: NetworkPanel

  keymap do
    only when: MODAL_INACTIVE do
      key "u", -> { Uptime.fetch_command }
    end
  end
end