Migrating from Elmish
Keep your Elmish init and update, and move views to Ranvier one at a time.
Mvu holds the model in a signal. Each view reads the parts it needs through selectors: memos that trigger their readers only when the selected value changes. The rest of the application can keep its Elmish loop.
Setup
Mvu ships in its own package, Ranvier.Elmish, which depends on Ranvier:
dotnet add package Ranvier.Elmish --prerelease
A model and its update
Create an app with Mvu.create init update. Read a field with app.Select, and send messages with app.Dispatch.
A counter with name and address fields
open Ranvier
open Ranvier.Elmish
type Model = { Count: int; Name: string; Address: Address }
and Address = { City: string; Zip: string }
type Msg =
| Increment
| Rename of string
| Move of string
let init = { Count = 0; Name = "Ada"; Address = { City = "Bergen"; Zip = "5003" } }
let update msg model =
match msg with
| Increment -> { model with Count = model.Count + 1 }
| Rename name -> { model with Name = name }
| Move city -> { model with Model.Address.City = city }
let graph = new Graph ()
graph.Run (fun () ->
let app = Mvu.create init update
let count = app.Select _.Count
createEffect (fun () -> printfn "count = %d" count.Value)
app.Dispatch Increment // count = 1
app.Dispatch (Rename "Grace") // the count effect stays asleep
)
This map isolates selector behaviour: incrementing changes the count, while renaming changes the model but leaves the count effect alone.
Dispatch msg applies update msg model and writes the resulting model to the signal.
app.Modeltracks the whole model. An effect reading it runs on every model change, like an Elmishview.app.Select ftracks the selected value. The selector recomputes on model writes while observed, but triggers its readers only when its result changes.
Test your understanding
In the counter example, does renaming Ada to Grace run the count effect again? Would an effect reading app.Model run?
Answer
The count selector recomputes, but still returns 1, so its effect does not run again. An effect reading app.Model runs because the model changed.
Commands
Use Mvu.withCmd when init and update return a model and a command list, as with Elmish's Program.mkProgram:
let update msg model =
match msg with
| Load -> { model with Loading = true }, [ fun dispatch -> fetch (fun items -> dispatch (Loaded items)) ]
| Loaded items -> { model with Loading = false; Items = items }, []
let app = Mvu.withCmd (initial, [ fun dispatch -> dispatch Load ]) update
Each command receives Dispatch and runs in list order after the accompanying model write. Initial commands run before withCmd returns.
Dispatch and threads
Under the default affinity, Dispatch runs inline on the graph's thread. Calls from other threads are queued and applied on that thread, through Graph.Dispatch.
Dispatch from an effect
A dispatch joins the flush already running. Neither update nor its commands become tracked parts of the effect.
Serialised graphs
Under ThreadAffinity.Serialised, dispatch runs inline only on the thread already inside the graph. Other calls, including calls on the construction context, queue for the next drain.
Without a captured SynchronizationContext, the graph drains only when graph.Pump () runs. Until then, the message stays queued. See Serialised hosts.
Selectors and their cost
Each model write recomputes every observed selector directly over the model. Unobserved selectors do not run. A selector compares its result; it does not diff a view.
Nest selectors to reduce this work. A memo over another selector runs only when that selector's result changes:
let address = app.Select _.Address
let city = createMemo (fun _ -> address.Value.City)
Test your understanding
After a write to Count, does address recompute? Does city recompute?
Answer
address recomputes if observed, but returns the same Address record. Propagation stops there, so city does not recompute.
Under the default equality policy, records compare by reference. A nested copy-and-update keeps records outside the changed path, allowing their selectors to cut off propagation. See Deep updates.
Selector costs and benchmark results
Like Elmish's lazy, selectors compare values to avoid downstream work, but at memo granularity. They still run to make that comparison.
| Per write, one field changed, one reader per field | Cost |
|---|---|
A signal per field (FieldSignalWrite) | One signal write; flat in the number of fields. |
A root signal and a selector per field (SelectorMemoWrite, MvuDispatch) | A model copy, plus one selector run per field. |
The model copy alone (ModelCopyOnly) | The allocation of the new model. |
FieldWriteBenchmarks in bench/Ranvier.Benchmarks measures these at 8, 64 and 256 fields. The counter scenario mvu-dispatch measures the first two at 64 fields, in retired instructions per write at commit e13f159, .NET 10 with tiered compilation and PGO off:
| Per write, 64 fields | Instructions | Bytes | Selector runs |
|---|---|---|---|
| A signal per field | 607 | 0 | 0 |
Mvu.Dispatch with a Select per field | 48,133 | 400 | 64 |
In this benchmark, dispatch re-runs all 64 observed selectors and costs about 80 times a field signal write. Nesting selectors reduces downstream runs to the changed path. See Instruction counts and the full report.
An adoption path
- Keep the whole-model view. Keep
initandupdate, replaceProgram.mkProgramwithMvu.withCmd, and run the existingviewfrom an effect readingapp.Model. Pass itapp.Dispatch. - Migrate one view. Read
app.Selectmemos in that view's effects instead ofapp.Model. - Repeat. Move the next view, nesting selectors when it displays part of a sub-model.
Owners instead of hooks
Selectors and effects belong to the scope active when they are created. Create a view inside createRoot or an owning memo to dispose its nodes, including selectors, with that scope.