Subscriptions
Subscriptions connect external event sources to your Elmish update loop. Unlike commands (one-time effects), subscriptions run continuously, dispatching messages whenever events occur.
Quick Start
open Mibo.Elmish
// Define subscription IDs
type SubId =
static member Keyboard = SubId.ofString "keyboard"
static member Network = SubId.ofString "network"
// Create a subscription
let keyboardSub : Sub<Msg> =
SubId.Keyboard,
fun dispatch ->
// Listen to events, dispatch messages
let handler = EventHandler<KeyboardEvent>(fun _ e ->
dispatch (KeyPressed e.Key)
)
Keyboard.addListener handler
// Return disposable to clean up
{ new IDisposable with
member _.Dispose() = Keyboard.removeListener handler
}
// In your program
Program.mkProgram init update
|> Program.withSubscription (fun _model -> keyboardSub)
How Subscriptions Work
The Elmish runtime diffs subscriptions by SubId each frame:
- New ID? Start the subscription
- Same ID? Keep it running
- ID gone? Dispose and stop
This gives you precise control over subscription lifetimes based on your model state.
Creating Subscriptions
Basic Subscription
let timerSub (interval: TimeSpan) : Sub<Msg> =
let id = SubId.ofString "timer"
id,
fun dispatch ->
let timer = new Timer(interval)
timer.Elapsed.Add(fun _ -> dispatch Tick)
timer.Start()
{ new IDisposable with
member _.Dispose() = timer.Dispose()
}
Conditional Subscriptions
Start/stop based on model state:
let subscribe model =
if model.IsConnected then
Sub.batch2 (
heartbeatSub,
messageListenerSub
)
else
Sub.none
Multiple Subscriptions
Function |
Use Case |
|---|---|
|
Variable list |
|
Exactly 2 (optimized) |
|
Exactly 3 (optimized) |
|
Exactly 4 (optimized) |
Subscription IDs
IDs must be unique per subscription. Use namespacing for parent-child composition:
module Player =
let inputSub : Sub<Player.Msg> =
SubId.ofString "input",
fun dispatch -> ...
// Parent prefixes child IDs:
let parentSub =
Player.inputSub |> Sub.map "player" PlayerMsg
// Resulting ID: "player/input"
Parent-Child Composition
Child modules often need their own subscriptions:
module Chat =
type Msg = NewMessage of string | ConnectionLost
let subscribe (model: Chat.Model) : Sub<Chat.Msg> =
if model.IsOpen then
SubId.ofString "chat/socket",
fun dispatch ->
let ws = new WebSocket("ws://server/chat")
ws.OnMessage.Add(fun e -> dispatch (NewMessage e.Data))
ws
else
Sub.none
// Parent wires it up:
type Parent.Msg = ChatMsg of Chat.Msg
let subscribe model =
model.Chat
|> Chat.subscribe
|> Sub.map "chat" ChatMsg // Prefix: "chat/chat/socket"
Common Patterns
Input Handling
For continuous input (not events):
let inputSub : Sub<Msg> =
SubId.ofString "input",
fun dispatch ->
// Mibo provides this via Input.subscribe
Input.subscribe (fun actions ->
dispatch (InputChanged actions)
)
Note: Mibo's built-in input system handles this for you. See Input.
Network Events
let networkSub (client: NetworkClient) : Sub<Msg> =
SubId.ofString "network",
fun dispatch ->
let handler = client.OnPacket.Subscribe(fun packet ->
dispatch (PacketReceived packet)
)
handler
Time-based
// Every second, dispatch a tick
let fpsSub : Sub<Msg> =
SubId.ofString "fps",
fun dispatch ->
let rec loop () = async {
do! Async.Sleep 1000
dispatch CalculateFps
return! loop()
}
let cts = new CancellationTokenSource()
Async.Start(loop(), cts.Token)
{ new IDisposable with
member _.Dispose() = cts.Cancel()
}
Lifecycle Management
The runtime automatically manages subscription lifecycles:
// Frame 1: Model says we need network
let subscribe model =
if model.Online then networkSub else Sub.none
// Runtime: Starts networkSub
// Frame 2: Model goes offline
// Runtime: Disposes networkSub (ID disappeared)
// Frame 3: Model back online
// Runtime: Starts fresh networkSub
Clean up resources in your disposable:
fun dispatch ->
let resource = acquireResource()
{ new IDisposable with
member _.Dispose() =
resource.Close()
resource.Dispose()
}
Performance Notes
- SubIds are strings - keep them stable (don't generate random IDs)
- The diff is O(N) on subscription count - don't create hundreds
- Disposables should be lightweight - move heavy cleanup to commands
See Also
- Input - Built-in input handling
- Commands - One-time side effects
- Elmish runtime - How the loop works
val string: value: 'T -> string
--------------------
type string = System.String
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) 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.Sleep: millisecondsDueTime: int -> Async<unit>
Mibo