Commands
View SourceCommands represent side effects in TermUI applications. They're returned from update/2 and executed asynchronously by the runtime.
Why Commands?
The Elm Architecture keeps update/2 pure - it only transforms state based on messages. Side effects like timers, file I/O, and HTTP requests are described as commands and executed by the runtime.
Benefits:
- Testable - Test state logic without mocking side effects
- Predictable - State changes are synchronous and traceable
- Composable - Combine multiple commands easily
Command Basics
Return commands from update/2:
def update(:start_timer, state) do
# Return new state AND a list of commands
{state, [Command.timer(1000, :timer_done)]}
end
def update(:timer_done, state) do
# Handle the result
{%{state | timer_fired: true}, []}
endAvailable Commands
Timer
Execute a message after a delay:
# Fire :timeout message after 5 seconds
Command.timer(5000, :timeout)
# With data in the message
Command.timer(1000, {:delayed_action, some_data})Quit
Request application shutdown:
def update(:quit, state) do
{state, [:quit]}
end
# Or using Command module
def update(:quit, state) do
{state, [Command.quit()]}
endThe runtime will:
- Stop accepting new events
- Clean up resources
- Restore terminal state
- Exit the process
None
Explicit no-op (useful for conditional commands):
def update(:maybe_save, state) do
cmd = if state.dirty do
Command.timer(0, :do_save)
else
Command.none()
end
{state, [cmd]}
endCommand Patterns
Debouncing
Delay action until input stops:
def init(_opts) do
%{search: "", debounce_ref: nil}
end
def update({:search_input, text}, state) do
# Cancel previous timer if any
commands = if state.debounce_ref do
[] # Previous timer will be ignored
else
[]
end
# Start new debounce timer
ref = make_ref()
commands = commands ++ [Command.timer(300, {:do_search, ref})]
{%{state | search: text, debounce_ref: ref}, commands}
end
def update({:do_search, ref}, %{debounce_ref: ref} = state) do
# Ref matches - this is the latest search
# Perform search...
{%{state | results: search(state.search)}, []}
end
def update({:do_search, _old_ref}, state) do
# Ref doesn't match - ignore stale search
{state, []}
endChained Operations
Sequence multiple async operations:
def update(:start_workflow, state) do
{%{state | step: :loading}, [Command.timer(0, :step_1)]}
end
def update(:step_1, state) do
# Do step 1...
{%{state | step: :step_1_done}, [Command.timer(100, :step_2)]}
end
def update(:step_2, state) do
# Do step 2...
{%{state | step: :step_2_done}, [Command.timer(100, :step_3)]}
end
def update(:step_3, state) do
{%{state | step: :complete}, []}
endPolling
Periodic updates:
def init(_opts) do
# Start polling immediately
%{data: nil}
end
def update(:init, state) do
{state, [Command.timer(0, :poll)]}
end
def update(:poll, state) do
# Fetch new data
new_data = fetch_data()
# Schedule next poll
{%{state | data: new_data}, [Command.timer(5000, :poll)]}
endConditional Commands
Build command list based on state:
def update(:save, state) do
commands = []
# Always show saving indicator
commands = commands ++ [Command.timer(0, :show_saving)]
# Maybe backup first
commands = if state.backup_enabled do
commands ++ [Command.timer(0, :backup)]
else
commands
end
# Do the save
commands = commands ++ [Command.timer(100, :do_save)]
{state, commands}
endError Handling
Handle command failures:
def update(:load_data, state) do
{%{state | loading: true}, [Command.timer(0, :do_load)]}
end
def update(:do_load, state) do
case fetch_data() do
{:ok, data} ->
{%{state | loading: false, data: data, error: nil}, []}
{:error, reason} ->
{%{state | loading: false, error: reason}, []}
end
end
def view(state) do
cond do
state.loading -> text("Loading...")
state.error -> text("Error: #{state.error}", Style.new(fg: :red))
true -> render_data(state.data)
end
endAnimation
Frame-based animation:
@frame_interval 50 # ~20 FPS
def init(_opts) do
%{frame: 0, animating: false}
end
def update(:start_animation, state) do
{%{state | animating: true, frame: 0}, [Command.timer(@frame_interval, :animate)]}
end
def update(:animate, %{animating: true} = state) do
next_frame = state.frame + 1
if next_frame >= 60 do
# Animation complete
{%{state | animating: false}, []}
else
# Continue animation
{%{state | frame: next_frame}, [Command.timer(@frame_interval, :animate)]}
end
end
def update(:animate, state) do
# Animation was stopped
{state, []}
end
def update(:stop_animation, state) do
{%{state | animating: false}, []}
endSpinner
Indeterminate progress indicator:
@spinner_frames ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"]
@spinner_interval 80
def init(_opts) do
%{loading: false, spinner_frame: 0}
end
def update(:start_loading, state) do
{%{state | loading: true}, [Command.timer(@spinner_interval, :spin)]}
end
def update(:spin, %{loading: true} = state) do
next_frame = rem(state.spinner_frame + 1, length(@spinner_frames))
{%{state | spinner_frame: next_frame}, [Command.timer(@spinner_interval, :spin)]}
end
def update(:spin, state) do
{state, []}
end
def update(:stop_loading, state) do
{%{state | loading: false}, []}
end
def view(state) do
if state.loading do
frame = Enum.at(@spinner_frames, state.spinner_frame)
text("#{frame} Loading...")
else
text("Ready")
end
endMultiple Commands
Return multiple commands at once:
def update(:initialize, state) do
commands = [
Command.timer(0, :load_config),
Command.timer(0, :load_data),
Command.timer(0, :start_heartbeat)
]
{state, commands}
endCommands execute concurrently. Results arrive as separate messages.
Testing Commands
Test that correct commands are returned:
defmodule MyApp.ComponentTest do
use ExUnit.Case
alias TermUI.Command
test "quit returns quit command" do
state = %{count: 0}
{_new_state, commands} = MyApp.Component.update(:quit, state)
assert :quit in commands
end
test "start timer returns timer command" do
state = %{}
{_new_state, commands} = MyApp.Component.update(:start, state)
assert [Command.timer(1000, :tick)] == commands
end
endCustom Commands
For operations not covered by built-in commands, use timer with immediate execution:
def update(:custom_operation, state) do
# Timer with 0 delay executes on next message loop
{state, [Command.timer(0, :do_custom)]}
end
def update(:do_custom, state) do
# Perform the operation synchronously
result = perform_custom_operation()
{%{state | result: result}, []}
endFor truly async operations (HTTP, file I/O), spawn a task:
def update(:fetch_data, state) do
# Start async task
Task.start(fn ->
result = HTTPClient.get(url)
# Send result back to runtime
send(self(), {:data_loaded, result})
end)
{%{state | loading: true}, []}
end
# In event_to_msg or handle_info
def event_to_msg({:data_loaded, result}, _state) do
{:msg, {:data_loaded, result}}
endNext Steps
- Elm Architecture - How commands fit in
- Events - Handle command results
- Widgets - Animated widgets