The reducer runtime is an alternative way to build supervised TUI applications with ExRatatui.App. Inspired by The Elm Architecture, it routes all messages through a single update/2 function and provides first-class primitives for side effects (ExRatatui.Command) and recurring timers (ExRatatui.Subscription).

This is the mode you want when:

  • Your TUI has async operations (HTTP calls, file I/O) and you want structured side-effect handling.
  • You need recurring timers that reconcile automatically when state changes.
  • You prefer a single message path over separate handle_event and handle_info callbacks.
  • You want built-in runtime inspection and tracing for debugging.

For simpler apps that don't need commands or subscriptions, see the Callback Runtime guide.

Quick Start

defmodule MyApp.TUI do
  use ExRatatui.App, runtime: :reducer

  alias ExRatatui.{Command, Event, Subscription}
  alias ExRatatui.Layout.Rect
  alias ExRatatui.Widgets.Paragraph

  @impl true
  def init(_opts) do
    {:ok, %{count: 0}, commands: [Command.message(:boot)]}
  end

  @impl true
  def render(state, frame) do
    area = %Rect{x: 0, y: 0, width: frame.width, height: frame.height}
    [{%Paragraph{text: "Count: #{state.count}"}, area}]
  end

  @impl true
  def update({:event, %Event.Key{code: "q", kind: "press"}}, state) do
    {:stop, state}
  end

  def update({:event, %Event.Key{code: "up", kind: "press"}}, state) do
    {:noreply, %{state | count: state.count + 1}}
  end

  def update({:info, :boot}, state) do
    {:noreply, %{state | count: 1}}
  end

  def update({:info, :tick}, state) do
    {:noreply, %{state | count: state.count + 1}}
  end

  def update(_msg, state), do: {:noreply, state}

  @impl true
  def subscriptions(_state) do
    [Subscription.interval(:heartbeat, 1_000, :tick)]
  end
end

Add it to your supervision tree or run directly:

children = [{MyApp.TUI, []}]
Supervisor.start_link(children, strategy: :one_for_one)

Callbacks

CallbackRequiredDescription
init/1NoCalled once on startup. Return {:ok, state} or {:ok, state, opts}. Defaults to {:ok, %{}}
render/2YesCalled after every state change. Return [{widget, rect}]
update/2NoReceives {:event, event} or {:info, message}. Return {:noreply, state}, {:noreply, state, opts}, or {:stop, state}. Defaults to {:noreply, state}
subscriptions/1NoCalled after each state transition. Return a list of Subscription structs. Defaults to []
terminate/2NoCalled on shutdown. Default is a no-op

The Message Path

Unlike the callback runtime which splits terminal events (handle_event/2) from mailbox messages (handle_info/2), the reducer runtime routes everything through update/2:

  • Terminal input arrives as {:event, event} — key presses, mouse clicks, resize events.
  • Mailbox messages arrive as {:info, message} — PubSub broadcasts, timer messages, async command results.
def update({:event, %Event.Key{code: "q"}}, state), do: {:stop, state}
def update({:event, %Event.Mouse{kind: "down"}}, state), do: {:noreply, state}
def update({:info, :tick}, state), do: {:noreply, state}
def update({:info, {:data_loaded, data}}, state), do: {:noreply, %{state | data: data}}
def update(_msg, state), do: {:noreply, state}

Runtime Options

Both init/1 and update/2 can return runtime options as a third element:

def init(_opts) do
  {:ok, %{count: 0}, commands: [Command.message(:boot)], trace?: true}
end

def update({:info, :background_work}, state) do
  {:noreply, state, render?: false, commands: [Command.async(fn -> do_work() end, &handle_result/1)]}
end
OptionDefaultDescription
commands: [...][]Side effects to execute after the state transition
render?: booltrueWhether to re-render after this transition
trace?: boolunchangedEnable or disable in-memory runtime tracing

Commands

Commands are one-shot side effects scheduled from init/1 or update/2. They execute after the new state has been committed and rendered.

Command.message/1

Send an immediate self-message:

Command.message(:refresh)
# The app receives {:info, :refresh} in update/2

Command.send_after/2

Schedule a delayed self-message:

Command.send_after(5_000, :timeout)
# After 5 seconds, the app receives {:info, :timeout}

Command.async/2

Run a function in the background and map the result back into an app message:

Command.async(
  fn -> HTTPClient.get!("/api/data") end,
  fn result -> {:data_loaded, result} end
)

The mapper receives the function's return value on success. If the function raises, exits, or throws, the mapper receives {:error, reason} instead. If the mapper itself fails, the runtime wraps that into an error tuple too — the async command always completes cleanly.

def update({:info, {:data_loaded, {:error, reason}}}, state) do
  {:noreply, %{state | error: reason}}
end

def update({:info, {:data_loaded, body}}, state) do
  {:noreply, %{state | data: body}}
end

Command.batch/1

Group multiple commands into a single return value:

Command.batch([
  Command.message(:refresh_ui),
  Command.async(fn -> fetch_data() end, &handle_data/1)
])

Command.none/0

Explicit no-op — useful when building commands conditionally:

commands = if state.auto_refresh, do: [Command.send_after(1_000, :refresh)], else: [Command.none()]
{:noreply, state, commands: commands}

Subscriptions

Subscriptions are recurring or one-shot timers declared in subscriptions/1. The runtime reconciles them after each state transition — diffing by stable ID so you don't need to manage timer references manually.

Subscription.interval/3

Repeated self-message at a fixed interval:

def subscriptions(state) do
  if state.polling? do
    [Subscription.interval(:poll, 1_000, :poll_data)]
  else
    []
  end
end

When state.polling? flips to false, the runtime automatically cancels the timer. When it flips back to true, a new timer starts. If the interval or message changes for the same ID, the old timer is cancelled and a new one is armed.

Subscription.once/3

One-shot self-message delivered once after a delay:

def subscriptions(_state) do
  [Subscription.once(:startup_delay, 2_000, :delayed_init)]
end

After firing, a :once subscription does not rearm — it stays in the subscription map as "fired" until the app removes it from subscriptions/1.

Subscription.none/0

Returns an empty list — explicit no-op:

def subscriptions(_state), do: [Subscription.none()]

Error Handling and Supervision

ExRatatui apps are supervised GenServers — standard OTP fault tolerance applies. The reducer runtime adds a few specifics:

  • init/1 raises or returns {:error, reason}: The server stops and the supervisor handles the restart. For SSH and distributed transports, the session is cleaned up and the client sees the connection close.

  • render/2 raises: The error is logged and the frame is skipped — the server continues running with the previous screen content.

  • update/2 raises: The server crashes and the supervisor restarts it. A fresh init/1 starts from scratch — all subscriptions are re-established.

  • Command.async/2 function raises: The error is caught and the mapper receives {:error, {:exception, message}}. If the mapper itself raises, the runtime wraps that into {:error, {:mapper_exception, message}}. In both cases, the result is delivered to update/2 as a normal {:info, ...} message — async commands always complete cleanly.

  • Subscription timers after crash: All timer references are lost on crash. After a supervisor restart, subscriptions/1 re-declares the timers and the runtime re-arms them from scratch.

  • Terminal restoration: On local transport, the terminal is automatically restored via the Rust ResourceArc finalizer when the reference is garbage collected.

  • SSH/distributed disconnection: The server detects the disconnect via monitors and shuts down cleanly, calling terminate/2.

For production deployments, set appropriate :max_restarts and :max_seconds on your supervisor to prevent restart loops. Use ExRatatui.Runtime.enable_trace/2 to capture state transitions leading up to a crash for post-mortem debugging.

Runtime Inspection

ExRatatui.Runtime provides runtime introspection that works with both callback and reducer apps:

{:ok, pid} = MyApp.TUI.start_link(name: nil)

# Snapshot of runtime metadata
snapshot = ExRatatui.Runtime.snapshot(pid)
snapshot.mode            #=> :reducer
snapshot.render_count    #=> 1
snapshot.subscription_count  #=> 1
snapshot.active_async_commands  #=> 0

# Enable in-memory tracing
:ok = ExRatatui.Runtime.enable_trace(pid, limit: 200)

# ... interact with the app ...

# Read trace events
events = ExRatatui.Runtime.trace_events(pid)

# Disable tracing
:ok = ExRatatui.Runtime.disable_trace(pid)

The snapshot includes:

  • mode, mod, and transport
  • dimensions and polling_enabled? (false under test_mode)
  • render_count and last_rendered_at
  • subscription_count and subscriptions (with id, kind, fired?, active?)
  • active_async_commands
  • trace_enabled?, trace_limit, and trace_events

Synthetic Event Injection

ExRatatui.Runtime.inject_event/2 delivers a synthetic terminal event through the same runtime transition path as polled input. This is the primary tool for testing supervised apps under test_mode:

event = %ExRatatui.Event.Key{code: "up", modifiers: [], kind: "press"}
:ok = ExRatatui.Runtime.inject_event(pid, event)

Running Over Transports

Reducer apps work across all three transports with zero code changes — exactly like callback apps:

children = [
  {MyApp.TUI, []},                                    # local TTY
  {MyApp.TUI, transport: :ssh, port: 2222, ...},      # remote over SSH
  {MyApp.TUI, transport: :distributed}                 # remote over distribution
]

See the Running TUIs over SSH and Running TUIs over Erlang Distribution guides for transport-specific setup.

Testing

test "increments count on up key" do
  {:ok, pid} = MyApp.TUI.start_link(name: nil, test_mode: {40, 10})

  event = %ExRatatui.Event.Key{code: "up", modifiers: [], kind: "press"}
  :ok = ExRatatui.Runtime.inject_event(pid, event)

  snapshot = ExRatatui.Runtime.snapshot(pid)
  assert snapshot.render_count >= 2

  GenServer.stop(pid)
end

test "subscription fires tick message" do
  {:ok, pid} = MyApp.TUI.start_link(name: nil, test_mode: {40, 10})

  snapshot = ExRatatui.Runtime.snapshot(pid)
  assert snapshot.subscription_count == 1

  # Wait for at least one tick
  Process.sleep(1_100)

  snapshot = ExRatatui.Runtime.snapshot(pid)
  assert snapshot.render_count >= 2

  GenServer.stop(pid)
end

Examples

  • examples/reducer_counter_app.exs — simple reducer-driven counter with subscriptions
  • switchyard — full-featured workbench exercising command batching, async effects, subscription reconciliation, runtime snapshots, trace toggles, render?: false quiet polling, distributed attach, and row-scrolled WidgetList (see full_featured_workbench.exs and the reducer app entrypoint)
  • phoenix_ex_ratatui_example — Phoenix app with a reducer-runtime stats TUI using Command.async, Command.send_after, Command.batch, and Subscription.interval, served over SSH and Erlang distribution alongside a public LiveView chat room
  • nerves_ex_ratatui_example — Nerves firmware with a reducer-runtime system monitor using Command.async and Subscription.interval, reachable over SSH subsystems and Erlang distribution