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_eventandhandle_infocallbacks. - 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
endAdd it to your supervision tree or run directly:
children = [{MyApp.TUI, []}]
Supervisor.start_link(children, strategy: :one_for_one)Callbacks
| Callback | Required | Description |
|---|---|---|
init/1 | No | Called once on startup. Return {:ok, state} or {:ok, state, opts}. Defaults to {:ok, %{}} |
render/2 | Yes | Called after every state change. Return [{widget, rect}] |
update/2 | No | Receives {:event, event} or {:info, message}. Return {:noreply, state}, {:noreply, state, opts}, or {:stop, state}. Defaults to {:noreply, state} |
subscriptions/1 | No | Called after each state transition. Return a list of Subscription structs. Defaults to [] |
terminate/2 | No | Called 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| Option | Default | Description |
|---|---|---|
commands: [...] | [] | Side effects to execute after the state transition |
render?: bool | true | Whether to re-render after this transition |
trace?: bool | unchanged | Enable 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/2Command.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}}
endCommand.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
endWhen 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)]
endAfter 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/1raises 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/2raises: The error is logged and the frame is skipped — the server continues running with the previous screen content.update/2raises: The server crashes and the supervisor restarts it. A freshinit/1starts from scratch — all subscriptions are re-established.Command.async/2function 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 toupdate/2as 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/1re-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, andtransportdimensionsandpolling_enabled?(falseundertest_mode)render_countandlast_rendered_atsubscription_countandsubscriptions(withid,kind,fired?,active?)active_async_commandstrace_enabled?,trace_limit, andtrace_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)
endExamples
examples/reducer_counter_app.exs— simple reducer-driven counter with subscriptionsswitchyard— full-featured workbench exercising command batching, async effects, subscription reconciliation, runtime snapshots, trace toggles,render?: falsequiet polling, distributed attach, and row-scrolled WidgetList (seefull_featured_workbench.exsand the reducer app entrypoint)phoenix_ex_ratatui_example— Phoenix app with a reducer-runtime stats TUI usingCommand.async,Command.send_after,Command.batch, andSubscription.interval, served over SSH and Erlang distribution alongside a public LiveView chat roomnerves_ex_ratatui_example— Nerves firmware with a reducer-runtime system monitor usingCommand.asyncandSubscription.interval, reachable over SSH subsystems and Erlang distribution
Related
ExRatatui.App— behaviour moduleExRatatui.Command— command constructorsExRatatui.Subscription— subscription constructorsExRatatui.Runtime— runtime inspection API- Callback Runtime — alternative runtime with separate event/info callbacks
- Building UIs — widgets, layout, styles, and events
- Running TUIs over SSH — SSH transport
- Running TUIs over Erlang Distribution — distribution transport