Once a TUI goes past a single screen, state starts to branch. You have a login flow, a main app, a settings panel, maybe a modal dialog on top of any of them. Stuffing that into a flat map and a big case in render/2 works for a while, then stops working.
This guide is a pattern catalog. All examples work with either runtime — the structure is the same; only the transition signature differs.
State-as-data vs branching in render/2
The first instinct is "render differently when this flag is set":
def render(state, frame) do
cond do
state.loading? -> loading_view(frame)
state.error -> error_view(state.error, frame)
state.settings_open? -> settings_view(state, frame)
true -> main_view(state, frame)
end
endThis grows fast. Three flags become eight combinations, and you never write tests for all of them. Swap the flags for a single :screen atom that names the state:
def render(%{screen: :loading} = state, frame), do: loading_view(state, frame)
def render(%{screen: :error} = state, frame), do: error_view(state, frame)
def render(%{screen: :settings} = state, frame), do: settings_view(state, frame)
def render(%{screen: :main} = state, frame), do: main_view(state, frame)Now the states are mutually exclusive by construction. Transitions become %{state | screen: :main} instead of %{state | loading?: false, error: nil, settings_open?: false}.
Mode atom → dispatch
Same trick for events. Don't pattern-match event codes at the top level — dispatch by screen first:
# Callback runtime
def handle_event(event, %{screen: :main} = state), do: handle_main(event, state)
def handle_event(event, %{screen: :settings} = state), do: handle_settings(event, state)
def handle_event(event, %{screen: :error} = state), do: handle_error(event, state)
defp handle_main(%Event.Key{code: "s"}, state), do: {:noreply, %{state | screen: :settings}}
defp handle_main(%Event.Key{code: "q"}, state), do: {:stop, state}
defp handle_main(_, state), do: {:noreply, state}
defp handle_settings(%Event.Key{code: "esc"}, state), do: {:noreply, %{state | screen: :main}}
defp handle_settings(_, state), do: {:noreply, state}Each screen owns its own keymap. Adding a new key to settings doesn't risk breaking main. Same pattern in the reducer runtime, just with update({:event, event}, state).
Screen stack for modals
A modal isn't really a new screen — it's a temporary layer on top of whatever's underneath. Model it as a second field:
%{
screen: :main, # underlying app
overlay: nil # or :confirm_quit, :help, :command_palette
}Render both, overlay last:
def render(state, frame) do
base = render_screen(state.screen, state, frame)
case state.overlay do
nil -> base
overlay -> base ++ render_overlay(overlay, state, frame)
end
endEvent dispatch checks the overlay first — if one's open, the underlying screen doesn't see input:
def handle_event(event, %{overlay: nil} = state), do: handle_screen(event, state)
def handle_event(event, state), do: handle_overlay(event, state)
defp handle_overlay(%Event.Key{code: "esc"}, state), do: {:noreply, %{state | overlay: nil}}
defp handle_overlay(%Event.Key{code: "y"}, %{overlay: :confirm_quit} = state), do: {:stop, state}
defp handle_overlay(_, state), do: {:noreply, state}This keeps the overlay logic in one place and prevents the "why did pressing 'q' close the modal and quit the app?" bug.
Modal via %Popup{}
%Popup{} does the rendering math — it centers a content widget over the given area and handles sizing:
defp render_overlay(:confirm_quit, _state, frame) do
popup = %Popup{
content: %Paragraph{
text: "Quit? (y/n)",
alignment: :center
},
percent_width: 30,
percent_height: 20,
block: %Block{title: " Confirm ", borders: [:all], border_type: :double}
}
[{popup, %Rect{x: 0, y: 0, width: frame.width, height: frame.height}}]
endPopup renders on top of whatever cells are already there — the Block's background clears the region. For modals that should dim the underlying UI, wrap in %Clear{} over a padded rect before the popup.
Multi-screen apps — explicit transitions
For login → main → settings, make transitions explicit commands/returns, not flag flips:
# Reducer runtime
def update({:event, %Event.Key{code: "enter"}}, %{screen: :login} = state) do
case authenticate(state.username, state.password) do
{:ok, user} -> {:noreply, %{state | screen: :main, user: user}}
{:error, reason} -> {:noreply, %{state | screen: :login, error: reason}}
end
end
def update({:event, %Event.Key{code: "s", modifiers: [:ctrl]}}, %{screen: :main} = state) do
{:noreply, %{state | screen: :settings, prev_screen: :main}}
end
def update({:event, %Event.Key{code: "esc"}}, %{screen: :settings, prev_screen: prev} = state) do
{:noreply, %{state | screen: prev, prev_screen: nil}}
endNote :prev_screen. When "esc" from settings should return to wherever you came from (main, or a sub-screen), track it in state. Don't hard-code :main — that breaks the day you add a second entry point to settings.
Conditional UI
Flags within a screen are still fine for small visibility toggles:
defp main_view(state, frame) do
panels =
[left_panel(state)] ++
if(state.show_debug?, do: [debug_panel(state)], else: []) ++
[right_panel(state)]
[...]
endA handful of these is fine. Once you have five booleans that combine meaningfully, promote to a screen atom or an explicit :mode field.
Focus-aware rendering
When focus matters to the visual — highlighted border on the focused panel, say — track it in state and derive styles in render/2:
defp panel_border(state, panel_id) do
if state.focus == panel_id do
%Style{fg: :yellow, modifiers: [:bold]}
else
%Style{fg: :dark_gray}
end
endFor larger apps with a ring of focusable IDs and tab cycling, see ExRatatui.Focus — it handles the ring navigation and lets you dispatch events to the currently-focused panel.
Loading / async states
Async work deserves its own screen atom when it's blocking (nothing else to do until it finishes), or a flag when it's backgrounded (user can still interact):
# Blocking: :loading is a screen
def update({:event, %Event.Key{code: "enter"}}, %{screen: :main} = state) do
command = Command.async(fn -> fetch_report() end, :report_loaded)
{:noreply, %{state | screen: :loading}, commands: [command]}
end
def update({:info, {:report_loaded, report}}, %{screen: :loading} = state) do
{:noreply, %{state | screen: :report, report: report}}
end
# Non-blocking: :refreshing? is a flag on :main
def update({:event, %Event.Key{code: "r"}}, %{screen: :main} = state) do
command = Command.async(fn -> refresh() end, :refreshed)
{:noreply, %{state | refreshing?: true}, commands: [command]}
endThe screen-atom version shows a full loading view; the flag version shows a throbber in the corner while the user keeps working.
Escape hatches — when to split processes
Everything above assumes state fits in one ExRatatui.App. That's usually the right default. But sometimes the state machine doesn't really belong to the UI:
- Background data that should survive a UI restart. A cache, a subscription to external events, a long-running computation.
- Shared state across multiple TUI sessions. Over SSH, each client gets its own
Server— if all clients should see the same data, that data lives in a sibling GenServer, not in app state. - Hardware or external resources. A serial port, a database connection, a websocket — these want their own lifecycle.
In those cases, spin up a separate GenServer (or Agent, or Registry) as a sibling under the same supervisor. Your ExRatatui.App calls into it in handle_event/2/update/2 and subscribes to its updates. The app stays focused on "what the user sees right now"; the sibling handles "what's true about the world."
# supervisor
children = [
MyApp.DataService, # owns the cache, subscribes to upstream
{MyApp.TUI, transport: :local}
]
# in TUI
def handle_event(%Event.Key{code: "r"}, state) do
MyApp.DataService.refresh() # fire-and-forget
{:noreply, state}
end
def handle_info({:data_updated, data}, state) do
{:noreply, %{state | data: data}}
endOver SSH or distribution, MyApp.DataService is one singleton; the TUI children are per-session. That's exactly what you want — one source of truth, many views.
Where to go next
- Callback Runtime — full
handle_event/handle_infoAPI. - Reducer Runtime —
update/2,Command,Subscription. - Building UIs —
ExRatatui.Focus, layout, styles. - Testing — asserting state-machine transitions deterministically.