State Machine Patterns

Copy Markdown View Source

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
end

This 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
end

Event 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.

%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}}]
end

Popup 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}}
end

Note :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)]

  [...]
end

A 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
end

For 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]}
end

The 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}}
end

Over 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