MVUish.Avalonia 2.0.0

dotnet add package MVUish.Avalonia --version 2.0.0
                    
NuGet\Install-Package MVUish.Avalonia -Version 2.0.0
                    
This command is intended to be used within the Package Manager Console in Visual Studio, as it uses the NuGet module's version of Install-Package.
<PackageReference Include="MVUish.Avalonia" Version="2.0.0" />
                    
For projects that support PackageReference, copy this XML node into the project file to reference the package.
<PackageVersion Include="MVUish.Avalonia" Version="2.0.0" />
                    
Directory.Packages.props
<PackageReference Include="MVUish.Avalonia" />
                    
Project file
For projects that support Central Package Management (CPM), copy this XML node into the solution Directory.Packages.props file to version the package.
paket add MVUish.Avalonia --version 2.0.0
                    
#r "nuget: MVUish.Avalonia, 2.0.0"
                    
#r directive can be used in F# Interactive and Polyglot Notebooks. Copy this into the interactive tool or source code of the script to reference the package.
#:package MVUish.Avalonia@2.0.0
                    
#:package directive can be used in C# file-based apps starting in .NET 10 preview 4. Copy this into a .cs file before any lines of code to reference the package.
#addin nuget:?package=MVUish.Avalonia&version=2.0.0
                    
Install as a Cake Addin
#tool nuget:?package=MVUish.Avalonia&version=2.0.0
                    
Install as a Cake Tool

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:

  1. Model & Update (The "MU"): Your application logic remains pure and immutable. You define a Model and an Update function that processes Messages to produce a new Model. This is the standard Elmish heart of the app.
  2. View (The "Reactive Binding"): The View function is called only once during initialization. Instead of receiving a snapshot of the Model, it receives an IObservable<Model>.
  3. 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.map
  • Subscription.Map / Sub.map
  • Subscription.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 live DataContext rather 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 BindItemsSource extension that binds the raw ItemsSource property. In F#, open MVUish.Avalonia.Collections after NXUI.Extensions so shadowing picks this adapter, as shown above. In C#, call CollectionExtensions statically 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):

Acknowledgements

The keyed subscription engine and the ValueTask command fast paths are ported from Elmish (Apache License 2.0, Copyright elmish contributors).

Product 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. 
Compatible target framework(s)
Included target framework(s) (in package)
Learn more about Target Frameworks and .NET Standard.

NuGet packages

This package is not used by any NuGet packages.

GitHub repositories

This package is not used by any popular GitHub repositories.

Version Downloads Last Updated
2.0.0 96 10/21/2026
1.0.1 91 10/17/2026
1.0.0 88 10/15/2026

### 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.