MVUish.Avalonia
2.0.0
dotnet add package MVUish.Avalonia --version 2.0.0
NuGet\Install-Package MVUish.Avalonia -Version 2.0.0
<PackageReference Include="MVUish.Avalonia" Version="2.0.0" />
<PackageVersion Include="MVUish.Avalonia" Version="2.0.0" />
<PackageReference Include="MVUish.Avalonia" />
paket add MVUish.Avalonia --version 2.0.0
#r "nuget: MVUish.Avalonia, 2.0.0"
#:package MVUish.Avalonia@2.0.0
#addin nuget:?package=MVUish.Avalonia&version=2.0.0
#tool nuget:?package=MVUish.Avalonia&version=2.0.0
MVUish.Avalonia
MVUish.Avalonia is a lightweight, performant Model-View-Update (MVU) library for Avalonia, designed for both C# and F#.
Unlike traditional MVU implementations (like Elm or Fabulous) that often rely on a Virtual DOM (VDOM) and tree diffing to update the UI, MVUish.Avalonia leverages Reactive Extensions (Rx) to create direct, fine-grained bindings between your state and your controls.
The "Reactive MVU" Difference
In a standard MVU loop, the View function is often called every time the Model changes, producing a new virtual tree which is then diffed against the previous one. While powerful, this can incur performance overhead and often requires wrappers around native controls.
MVUish.Avalonia takes a different approach, effectively implementing a Model-Update-Observable pattern:
- Model & Update (The "MU"): Your application logic remains pure and immutable. You define a
Modeland anUpdatefunction that processesMessagesto produce a newModel. This is the standard Elmish heart of the app. - View (The "Reactive Binding"): The
Viewfunction is called only once during initialization. Instead of receiving a snapshot of theModel, it receives anIObservable<Model>. - Direct Updates: You build your UI using standard Avalonia controls (or helpers like NXUI) and bind specific properties (like
Text,Color,IsVisible) directly to the observable stream.
Why this approach?
- Zero VDOM Overhead: There is no tree diffing. Changes propagate directly from the Model stream to the target properties.
- High Performance: Only the specific properties that change are updated.
- Native Control Interop: Since the View returns actual Avalonia controls, you can easily use any 3rd party control, existing behaviors, or complex layouts without needing specialized wrappers.
Subscriptions and lifetimes
Subscriptions follow the Elmish v4 model: a subscription set is a function of the model, re-evaluated after every update, and the runtime starts or stops sources to match. Sources are keyed, so child modules compose without collisions and settings can restart a source by embedding them in the id. Built-in bridges cover timers, .NET events, any IObservable<'Msg>, and Avalonia routed events.
Programs own their resources. Program.runWithLifecycle returns the handle that stops subscriptions, completes the model stream, and stops the update loop; Program.runWithLifetime ties that release to a control leaving the visual tree; Program.withTermination makes a message do it. This fixes the 1.0 behavior where subscriptions and the update loop ran forever.
Getting Started
Requires .NET 10 and Avalonia 12. The samples and templates also use NXUI 12 for code-built views.
This library provides a unified API for both C# and F# developers, treating both as first-class citizens.
Documentation is published at angelmunoz.github.io/MVUish.Avalonia.
C# Example
We provide a specialized MVUish.Avalonia.CSharp module to hide F# specifics and provide a familiar, idiomatic C# API.
using static MVUish.Avalonia.CSharp;
public static class CounterModule
{
// 1. Model: Immutable state
public record Model(int Count);
// 2. Message: Actions that change state
public record Msg { public record Increment : Msg; }
// 3. Init: Initial state
public static (Model, Action<Action<Msg>>) Init() =>
(new Model(0), Command.None<Msg>());
// 4. Update: State transition
public static (Model, Action<Action<Msg>>) Update(Msg msg, Model model) =>
msg switch {
Msg.Increment => (model with { Count = model.Count + 1 }, Command.None<Msg>()),
_ => (model, Command.None<Msg>())
};
// 5. View: Reactive UI construction (Called Once)
public static Control View(IObservable<Model> model, Action<Msg> dispatch)
{
// Select specific data from the model stream
var countText = model.Select(m => $"Count: {m.Count}");
return StackPanel().Children(
TextBlock().Text(countText),
Button().Content("Increment").OnClick(e =>
e.Subscribe(_ => dispatch(new Msg.Increment()))
)
);
}
}
F# Example
module Counter
type Model = { Count: int }
type Msg = Increment
let init () = { Count = 0 }, Cmd.none
let update msg model =
match msg with
| Increment ->
{ model with Count = model.Count + 1 }, Cmd.none
let view (model: IObservable<Model>) dispatch =
let countText = model |> Observable.map(fun m -> sprintf "Count: %d" m.Count)
StackPanel().Children(
TextBlock().Text(countText),
Button().Content("Increment").OnClick(fun _ obs ->
obs |> Observable.add(fun _ -> dispatch Increment)
)
)
Composition and Scaling
MVUish.Avalonia supports scaling your application through Module Composition. You can nest components by mapping their Commands and Subscriptions, allowing you to build complex applications from small, isolated modules.
Command.Map/Cmd.mapSubscription.Map/Sub.mapSubscription.Batch/Sub.batch
Check the Samples folder for a comprehensive Dashboard example showcasing parent-child communication, async commands, and subscriptions in both languages.
Collections
Assigning a new list to ItemsSource on every model change rebuilds every item container: selection, scroll position, and any state a row holds are lost. The collection bindings instead keep one owned ObservableCollection as ItemsSource and apply each snapshot from the model as minimal deltas, so containers are reused.
Both bindings assign ItemsSource once and return the control, so they chain in NXUI-style builders like the rest of the library. There is no disposable to manage: a binding keeps applying snapshots while its control is alive — including while detached from the visual tree — and ends itself on the first emission after the control is collected.
Keyed binding (preferred)
Match items by a stable key. Items whose key disappears are removed, new keys are inserted, a surviving key with a changed item raises a Replace, and reorders raise Move events.
open NXUI.Extensions
// Open the MVU namespaces after NXUI: F# extension shadowing must pick
// these bindings over the NXUI property binder of the same name.
open MVUish.Avalonia
open MVUish.Avalonia.Collections
type Note = { Id: Guid; Text: string }
let view (model: IObservable<Model>) (dispatch: Dispatch<Msg>) =
let notes = model |> Observable.map (fun m -> m.Notes)
// The list-typed overload accepts IObservable<Note list> directly and
// returns the control, so it chains with the NXUI builders.
ListBox().MinHeight(160.0).BindItemsSource(notes, (fun n -> n.Id))
using MVUish.Avalonia.Collections;
// Returns the list box, so the call chains in NXUI-style builders.
var listBox = CollectionExtensions.BindItemsSource(
new ListBox { MinHeight = 160 }, model.Select(m => m.Notes), n => n.Id);
A snapshot that repeats a key throws ArgumentException. Inside a Program view the throw surfaces through the program's OnError handler instead of crashing the app.
Containers are reused while they are realized. Once items scroll past the viewport, Avalonia's virtualizing panels recycle containers, so control-local row state cannot survive: a non-recycling template is rebuilt, and a recycling template is reused for another item. A list that virtualizes and needs to keep row state must use a recycling template (
supportsRecycling: true) with the state on the model item; handlers must read the liveDataContextrather than a captured item, and the row must rehydrate from it. A non-recycling template is only safe when the list cannot scroll. The sample shows the shape. See Collections.
NXUI ships its own
BindItemsSourceextension that binds the rawItemsSourceproperty. In F#, openMVUish.Avalonia.CollectionsafterNXUI.Extensionsso shadowing picks this adapter, as shown above. In C#, callCollectionExtensionsstatically as shown above.
Structural binding
BindItemsSourceStructural (Extensions.BindItemsSourceStructural in F#, CollectionExtensions.BindItemsSource without a key in C#) diffs by structural equality instead of keys. Repeated values are allowed. Prefer the keyed binding: it also matches moved items.
Whole-list fallback
For short lists where none of the row state matters, BindStream on ItemsControl.ItemsSourceProperty still works — every emission reassigns the whole list and Avalonia resets the control. That is the cheapest option for a handful of items.
Threading
Both bindings apply changes on the thread that emits the snapshot. Program dispatches on the UI thread, so model streams need nothing extra. Sources that emit from background threads must hop first, for example with Observable.observeOn on Avalonia.Threading.Dispatcher.UIThread.
Project templates
A dotnet new template pack lives in templates/:
dotnet pack templates/MVUish.Avalonia.Templates.csproj
dotnet new install <path-to-the-produced-nupkg>
dotnet new mvu-fsharp -o MyApp
dotnet new mvu-csharp -o MyApp
Each template generates a minimal clock app wired with this library and NXUI. Generated apps reference the published MVUish.Avalonia package (2.*); inside this repository the template projects build against the library source instead, through templates/Directory.Build.props.
Existing Alternatives
If you prefer the VDOM approach or need strict Elm architecture compliance (where View is a pure function of Model → UI Tree):
- Avalonia.FuncUI - A VDOM-based MVU library, very similar to Elm/React.
- Fabulous.Avalonia - MVU framework for .NET MAUI and Avalonia using VDOM.
- Elmish.Avalonia - Elmish for Avalonia with static XAML views.
Acknowledgements
The keyed subscription engine and the ValueTask command fast paths are ported from Elmish (Apache License 2.0, Copyright elmish contributors).
| Product | Versions Compatible and additional computed target framework versions. |
|---|---|
| .NET | net10.0 is compatible. net10.0-android was computed. net10.0-browser was computed. net10.0-ios was computed. net10.0-maccatalyst was computed. net10.0-macos was computed. net10.0-tvos was computed. net10.0-windows was computed. |
-
net10.0
- Avalonia (>= 12.0.5)
- FSharp.Control.Reactive (>= 6.1.2)
- FSharp.Core (>= 10.1.402)
NuGet packages
This package is not used by any NuGet packages.
GitHub repositories
This package is not used by any popular GitHub repositories.
### Added
- Keyed, model-driven subscriptions (Elmish v4 semantics): `Sub<'Msg>` is a list of `(SubId * Subscribe<'Msg>)` pairs; the program re-evaluates `subscribe` after every update and starts or stops sources to match, reporting duplicate ids to the error handler. Fixes the 1.0 behavior where a subscription started once with the initial model and never stopped (issue 6's leak chain).
- Program lifetimes: `Program.runWithLifecycle` returns a view plus an `IDisposable` that stops subscriptions, completes the model stream, and stops the update loop; disposing twice is a no-op and dispatch after dispose is dropped.
- Visual-tree lifetimes: `Program.runWithLifetime control arg program` disposes the program when the control first detaches from the visual tree.
- Termination: `Program.withTermination` / `mapTermination` register an exit message plus a final-model handler.
- Subscription helpers: `Sub.ofId`, `Sub.fromObservable` (bridges any `IObservable<'Msg>`), `Sub.fromRoutedEvent` / `Sub.fromRoutedEventWith` (Avalonia routed events with routing strategies and handled-events-too), and `Sub.untilDetached`.
- Commands: `Cmd.ofEffect` (dispatch-aware effect), `Cmd.ofAction` (plain `unit -> unit` action for view-side effects), `either` variants for `OfFunc`/`OfAsync`/`OfTask`, `Cmd.OfAsyncWith` (custom async start), and `Cmd.OfValueTask` with completed-synchronously fast paths (ported from Elmish, Apache 2.0).
- Dev tools: `Program.withTrace` (message, model, wanted sub ids), `Program.mapSubscription`, `Program.mapErrorHandler`, `Program.onError`; `withConsoleTrace` now logs active sub ids.
- C#: `CSub<'Msg>` named subscription handle with the `Subscription` factories; `Command.OfAction/Attempt/Either/AttemptTask/EitherTask` and the ValueTask trio; builder `WithTermination`, `WithTrace`, `RunWithLifecycle`, `RunWithLifetime`; `ProgramHandle<'View>` result type.
- Documentation: a Subscriptions guide and a 1.0-to-2.0 migration guide.
### Changed
- **Breaking:** `Sub<'Msg>` changed from a single start function to a keyed list; `Sub.none` is `[]`, `Sub.batch` concatenates sets, and `Sub.map` gained an id-prefix parameter.
- **Breaking:** `Sub.interval` and `Sub.fromEvent` gained an id parameter.
- **Breaking:** the `Program` record gained a `Termination` field; records constructed by hand must add it (or use `mkProgram`/`mkSimple`).
- **Breaking:** C# `WithSubscription` now takes `Func<'Model, CSub<'Msg>>` built from the `Subscription` helpers instead of a raw delegate.
- The loop now renders before reconciling subscriptions and runs the init command after both (Elmish's order), instead of running the init command before the view and subscriptions exist.
- A failing view factory reports to `OnError` and yields the default view instead of throwing out of `runWithLifecycle`.
- Templates reference package version `2.*`.
### Fixed
- Disposing a program no longer reports a spurious `ObjectDisposedException` to `OnError` when a message is still queued in the update loop. Messages accepted before the stop are dropped silently.