Skip to content

Migration Guide: Xamarin to .NET MAUI

Xamarin.Forms support ended in May 2024. .NET MAUI is its successor: one project instead of one per platform, and current .NET releases instead of .NET Standard. Microsoft's own Xamarin.Forms to MAUI migration guide and the .NET Upgrade Assistant cover the general project, XAML and resource changes. This guide covers only the ReactiveUI-specific part: the package, the app's start-up code, the view base classes and navigation.

Prerequisites

  • Visual Studio 2022 17.8 or later, or VS Code with the .NET MAUI extension.
  • The .NET 8.0 SDK or later.
  • The MAUI workload: dotnet workload install maui.

Update the package reference

Before

<PackageReference Include="Xamarin.Forms" />
<PackageReference Include="Xamarin.Essentials" />
<PackageReference Include="ReactiveUI.XamForms" />

After

<PackageReference Include="Microsoft.Maui.Controls" Version="*" />
<PackageReference Include="ReactiveUI.Maui" Version="*" />

ReactiveUI.Maui brings ReactiveUI and MAUI's ReactiveUI.Binding platform package with it, so WhenAnyValue, Bind and ToProperty are ready to use with no extra package. Migration Guide: Bindings on ReactiveUI.Binding covers that package. Reference ReactiveUI.Maui.Reactive instead when your app uses System.Reactive rather than ReactiveUI.Primitives.

Start the app

Xamarin.Forms configured ReactiveUI in the App constructor. MAUI configures it in MauiProgram.CreateMauiApp, through MauiAppBuilder.UseReactiveUI.

Before

public partial class App : Application
{
    public App()
    {
        InitializeComponent();

        Locator.CurrentMutable.RegisterLazySingleton<IScreen>(() => new MainViewModel());
        Locator.CurrentMutable.Register<IViewFor<MainViewModel>>(() => new MainPage());

        MainPage = new NavigationPage(new MainPage());
    }
}

After

MauiAppBuilder mauiBuilder = MauiApp.CreateBuilder();

_ = mauiBuilder.UseReactiveUI(builder =>
{
    _ = builder.WithMauiConverters();
});

UseReactiveUI takes either a Action<IReactiveUIBuilder>, as above, or a MAUI IDispatcher and configures the builder for you:

MauiAppBuilder mauiBuilder = MauiApp.CreateBuilder();

MauiAppBuilder configured = mauiBuilder.UseReactiveUI(new ImmediateDispatcher());

Console.WriteLine(ReferenceEquals(mauiBuilder, configured));

// Output:
// True

A real app's delegate calls WithMaui(), which registers MAUI's converters, its platform module and its scheduler in one call, then adds its own views, view models and services:

IReactiveUIBuilder builder = RxAppBuilder.CreateReactiveUIBuilder()
    .WithMaui(new ImmediateDispatcher());

Console.WriteLine(builder is not null);

// Output:
// True

Migration Guide: RxAppBuilder covers WithRegistration, RegisterView and the rest of the builder's registration calls, and RxAppBuilder is the full reference. An App.xaml.cs no longer registers anything; it only sets MainPage or, on Shell apps, AppShell.

Move the views

ReactiveUI.Maui replaces ReactiveUI.XamForms. Its base classes pair a Microsoft.Maui.Controls type with IViewFor<T>, and every one exposes the same ViewModel property kept in sync with BindingContext:

MAUI controlReactiveUI base class
ContentPageReactiveContentPage<TViewModel>
NavigationPageReactiveNavigationPage<TViewModel>
FlyoutPageReactiveFlyoutPage<TViewModel>, or ReactiveMasterDetailPage<TViewModel> under its old name
CarouselViewReactiveCarouselView<TViewModel>
ContentViewReactiveContentView<TViewModel>
WindowReactiveWindow<TViewModel>
TitleBarReactiveTitleBar<TViewModel>
ShellReactiveShell<TViewModel>
Page (older, superseded by ReactiveContentPage<TViewModel>)ReactivePage<TViewModel>
RecipeListPage page = new();
RecipeListViewModel first = new(new RecipeBookScreen());
RecipeListViewModel second = new(new RecipeBookScreen());

page.ViewModel = first;
Console.WriteLine(ReferenceEquals(page.BindingContext, first));

page.BindingContext = second;
Console.WriteLine(ReferenceEquals(page.ViewModel, second));

Console.WriteLine(ReactiveContentPage<RecipeListViewModel>.ViewModelProperty.PropertyName);

// Output:
// True
// True
// ViewModel

The class declares IViewFor<T>, so ReactiveUI.Binding's view locator finds it at compile time; a MAUI app needs no RegisterViewsForViewModels or RegisterView<TView, TViewModel>() call for a page like this one:

public sealed class RecipeListPage : ReactiveContentPage<RecipeListViewModel>;

Migration Guide: Bindings on ReactiveUI.Binding covers that generated lookup. Register a view in the service locator only when it needs constructor arguments, or when two views serve the same view model under different contracts.

View models change the least

A view model that derives from ReactiveObject needs no change: RaiseAndSetIfChanged, WhenAnyValue, ToProperty and ReactiveCommand all work the same way on MAUI as they did on Xamarin.Forms.

public sealed class Student : ReactiveObject
{
    private readonly List<int> _grades = [];

    public string Name
    {
        get;
        set => this.RaiseAndSetIfChanged(ref field, value);
    } = string.Empty;
public RecipeListViewModel(IScreen hostScreen)
{
    HostScreen = hostScreen;
    Open = ReactiveCommand.CreateFromObservable<RecipeItem, IRoutableViewModel>(
        recipe => HostScreen.Router.Navigate.Execute(new RecipeDetailViewModel(HostScreen, recipe)));
}

ReactiveUI.SourceGenerators comes with ReactiveUI on MAUI as on every platform: its [Reactive] and [ReactiveCommand] attributes write the property and command bodies for you. Remove any explicit ReactiveUI.SourceGenerators reference older than 4.0.0 from the project, or restore fails with NU1605. Source generators covers the attributes.

Move navigation onto RoutingState

Xamarin.Forms code that called Navigation.PushAsync, PopAsync or PushModalAsync moves onto ReactiveUI's own router: a RoutingState that holds a stack of view models, and a RoutedViewHost that keeps a MAUI NavigationPage in step with it. Both types ship in ReactiveUI itself; no extra package is needed.

Before

await Navigation.PushAsync(new DetailsPage());
await Navigation.PopAsync();

After

A page implements IRoutableViewModel and navigates through its HostScreen's router:

public RecipeListViewModel(IScreen hostScreen)
{
    HostScreen = hostScreen;
    Open = ReactiveCommand.CreateFromObservable<RecipeItem, IRoutableViewModel>(
        recipe => HostScreen.Router.Navigate.Execute(new RecipeDetailViewModel(HostScreen, recipe)));
}

RoutedViewHost needs a registered IScreen before it can find the router, and its SyncAsync() pushes the router's stack onto the host's own Navigation.NavigationStack:

RecipeBookScreen screen = new();
AppLocator.CurrentMutable.RegisterConstant<IScreen>(screen);
AppLocator.CurrentMutable.Register<IViewFor<RecipeListViewModel>>(static () => new RecipeListPage());

RecipeRoutedViewHost host = new();
screen.Router.NavigationStack.Add(new RecipeListViewModel(screen));

await host.SyncAsync();

Console.WriteLine(host.Navigation.NavigationStack.Count);
Console.WriteLine(host.Navigation.NavigationStack[0].GetType().Name);

// Output:
// 1
// RecipeListPage

Routing covers RoutingState, NavigationStack, Navigate and NavigateBack in full, from a platform-independent example. Keep navigation on RoutingState rather than mixing it with MAUI Shell's own Routing.RegisterRoute and GoToAsync; the two stacks do not know about each other.

Replace MessagingCenter

Xamarin.Forms's MessagingCenter is gone in MAUI. ReactiveUI's MessageBus sends a typed message to every subscriber, and works the same way it did on Xamarin.Forms:

Before

MessagingCenter.Send(this, "Message", data);
MessagingCenter.Subscribe<T>(this, "Message", callback);

After

IMessageBus original = MessageBus.Current;
MessageBus.Current = new MessageBus();

using IDisposable subscription = MessageBus.Current.Listen<Announcement>().Subscribe(static announcement => Console.WriteLine(announcement.Text));
MessageBus.Current.SendMessage(new Announcement("Assembly starts at 9am"));

MessageBus.Current = original;

// Output:
// Assembly starts at 9am

Message bus covers scoped instances, contracts and the delivery scheduler.

Dependency injection replaces DependencyService

DependencyService.Get<T>() is gone from MAUI. Register a service in MauiProgram with builder.Services.AddSingleton<IMyService, MyService>() and receive it through a constructor parameter, the same as any other ASP.NET-style dependency injection. ReactiveUI's own AppLocator keeps working alongside it; RxAppBuilder registers into whichever resolver you point it at, including one backed by Microsoft.Extensions.DependencyInjection. RxAppBuilder covers registration lifetimes.

Testing needs no change

A view model test that builds a view model directly and drives its commands and properties keeps working unchanged, since it never touched Xamarin.Forms in the first place. Testing covers TestScheduler and the builder-based test fixture pattern for anything that reads RxSchedulers or AppLocator.

Where to go next