Commands
Commands handle side effects in Mibo's Elmish architecture. While update should be pure, commands let you execute impure work (I/O, timers, random) and dispatch messages back into the loop.
Quick Start
open Mibo.Elmish
type Msg =
| SaveGame
| SaveComplete
| SaveFailed of exn
let update msg model =
match msg with
| SaveGame ->
// Return unchanged model + command to save
model, Cmd.ofAsync (saveToDisk model) SaveComplete SaveFailed
| SaveComplete ->
printfn "Game saved!"
model, Cmd.none
Command Basics
Commands are values returned from init and update alongside the new model:
let update msg model : struct(Model * Cmd<Msg>) =
match msg with
| Tick ->
{ model with Time = model.Time + 1 }, Cmd.none
| Fire ->
model, Cmd.ofMsg SpawnProjectile
Cmd.none means "no side effects this frame."
Creating Commands
Immediate Messages
Dispatch another message right away:
Cmd.ofMsg SpawnProjectile
Async Workflows
async { ... } is F#'s syntax for asynchronous work: let! awaits a result without blocking a thread. Run F# async and map results:
let loadData url = async {
use client = new HttpClient()
let! json = client.GetStringAsync(url) |> Async.AwaitTask
return parseJson json
}
// In update:
model, Cmd.ofAsync (loadData "api/data") DataLoaded LoadFailed
The async runs on a background thread. When it completes, the result message is dispatched back into the game loop.
.NET Tasks
For existing Task-based APIs:
let task = File.ReadAllTextAsync("save.json")
model, Cmd.ofTask task LoadComplete LoadFailed
Custom Effects
Effect<'Msg> wraps a function that receives dispatch; use it for full control:
let runDelayed (ms: int) (msg: Msg) (dispatch: Msg -> unit) =
async {
do! Async.Sleep ms
dispatch msg
}
|> Async.StartImmediate
let delayedMsg (ms: int) (msg: Msg) : Cmd<Msg> =
Cmd.ofEffect (Effect<Msg>(runDelayed ms msg))
// Usage
model, delayedMsg 1000 (DelayedAction "1 second passed")
Combining Commands
Return multiple commands from one update:
let startLevelCmd (level: Level) =
Cmd.batch [
Cmd.ofMsg (PlayMusic level.Music)
Cmd.ofAsync loadLevelData level.id LevelDataLoaded LoadFailed
Cmd.ofMsg SpawnPlayer
]
let update msg model =
match msg with
| StartLevel level ->
{ model with Level = level }, startLevelCmd level
Function |
Use Case |
|---|---|
|
Variable list of commands |
|
Exactly 2 commands (optimized) |
|
Exactly 3 commands (optimized) |
|
Exactly 4 commands (optimized) |
Deferred Commands
Sometimes you need to break infinite loops or schedule work for the next frame:
let update msg model =
match msg with
| CheckCondition ->
if stillNeedToCheck then
// Check again next frame, not immediately
model, Cmd.deferNextFrame (Cmd.ofMsg CheckCondition)
else
model, Cmd.none
deferNextFrame prevents stack overflow when messages trigger themselves.
Parent-Child Composition
Child components often have their own message types. Map them to parent messages:
module Child =
type Msg = Jump | Move
let update msg model = ...
// Parent update:
let update msg model =
match msg with
| ChildMsg childMsg ->
let (childModel, childCmd) = Child.update childMsg model.Child
let parentModel = { model with Child = childModel }
// Map child's Cmd<Child.Msg> to Cmd<Parent.Msg>
parentModel, Cmd.map ChildMsg childCmd
Common Patterns
Fire-and-Forget
For effects where you don't care about the result:
let logEffect (msg: string) (_dispatch: Msg -> unit) =
printfn "[LOG] %s" msg
let logMessage (msg: string) =
Cmd.ofEffect (Effect<Msg>(logEffect msg))
// Usage
model, logMessage "Player jumped"
Sequential Commands
The batch functions run their commands in the same frame; the order between them is not a wait. For true sequencing (B runs after A completes), use async:
let saveThenLoad path =
Cmd.ofAsync (async {
do! saveDataAsync()
let! result = loadDataAsync()
return result
}) Loaded Failed
To run two independent effects together in one frame, batch them:
let saveAndStartNext (path: string) =
let saved _ = DataSaved
Cmd.batch2 (
Cmd.ofAsync saveData path saved SaveFailed,
Cmd.ofMsg LoadNextLevel // starts immediately, not after the save
)
Conditional Commands
let maybeSave model =
if model.Dirty then
Cmd.ofAsync autoSave model SaveComplete SaveFailed
else
Cmd.none
// In update:
model, maybeSave model
Performance Notes
- Commands are structs: minimal allocation
Cmd.noneis a singleton: zero allocationbatch2/batch3/batch4avoid array allocations- Async commands don't block the game loop
See Also
- Elmish runtime: The update loop
- Subscriptions: External event sources
- Service composition: Dependency injection patterns
type Async = static member AsBeginEnd: computation: ('Arg -> Async<'T>) -> ('Arg * AsyncCallback * objnull -> IAsyncResult) * (IAsyncResult -> 'T) * (IAsyncResult -> unit) static member AwaitEvent: event: IEvent<'Del,'T> * ?cancelAction: (unit -> unit) -> Async<'T> (requires delegate and 'Del :> Delegate and 'Del: not null) static member AwaitIAsyncResult: iar: IAsyncResult * ?millisecondsTimeout: int -> Async<bool> static member AwaitTask: task: Task<'T> -> Async<'T> + 1 overload static member AwaitWaitHandle: waitHandle: WaitHandle * ?millisecondsTimeout: int -> Async<bool> static member CancelDefaultToken: unit -> unit static member Catch: computation: Async<'T> -> Async<Choice<'T,exn>> static member Choice: computations: Async<'T option> seq -> Async<'T option> static member FromBeginEnd: beginAction: (AsyncCallback * objnull -> IAsyncResult) * endAction: (IAsyncResult -> 'T) * ?cancelAction: (unit -> unit) -> Async<'T> + 3 overloads static member FromContinuations: callback: (('T -> unit) * (exn -> unit) * (OperationCanceledException -> unit) -> unit) -> Async<'T> ...
--------------------
type Async<'T>
static member Async.AwaitTask: task: System.Threading.Tasks.Task<'T> -> Async<'T>
val int: value: 'T -> int (requires member op_Explicit)
--------------------
type int = int32
--------------------
type int<'Measure> = int
static member Async.Sleep: millisecondsDueTime: int -> Async<unit>
val string: value: 'T -> string
--------------------
type string = System.String
Mibo