guides, terra

Effects as data

view/1 is pure: it takes state and returns a tree of text, stacks and boxes. It never reads a file, runs a shell command, or talks to the network. Effects in Terra are commands, and commands are plain data returned from init/1 and update/2.

def update(:refresh, state) do
{state, [{:read_file, "data/todos.txt", :loaded}]}
end

The runtime executes the command off the view path and delivers the result to update/2 like any other message, wrapped as {msg, result}:

def update({:loaded, {:ok, contents}}, state), do: %{state | text: contents}
def update({:loaded, {:error, reason}}, state), do: %{state | error: inspect(reason)}

The shapes

Command Delivered to update/2
{:tick, ms, msg} msg after ms milliseconds
{:read_file, path, msg} {msg, {:ok, contents} | {:error, reason}}
{:port, cmd, msg} {msg, {:ok, {lines, status}}} when the command finishes, {msg, {:error, reason}} when the port cannot start, closes, or crashes

A non-zero exit status from a port command is data, not an error: the command ran and produced output, and your app decides what that output means. A failing mix test run is still parseable.

Return a list of commands to schedule several at once, and return commands from init/1 to start work immediately:

def init(_opts) do
{%{status: :running}, [{:port, "mix test --no-color", :ran}, {:tick, 150, :tick}]}
end

The worked examples

Both apps are plain Terra.App modules you can read top to bottom, and both are driven headlessly in the test suite.

The restore contract still holds

Commands change nothing about the terminal. Whether your app quits from update/2, receives Ctrl+C, crashes in a callback, or dies while a port is still running, the runtime restores cooked mode, a visible cursor and the main screen. Pending command runners are stopped before the restore, so a slow mix test cannot outlive its window.