Skip to content

Migration Guide: Bindings on ReactiveUI.Binding

ReactiveUI now runs WhenAnyValue, Bind, OneWayBind, BindCommand and ToProperty on ReactiveUI.Binding. Most of your code keeps compiling as it is. This guide covers the calls that need a change, and the places where the new engine behaves differently.

ReactiveUI.Binding writes the code for each binding while your project builds. A source generator is the part of the compiler that writes it. An analyzer is the part that checks your calls and reports a warning when it cannot write code for one. View location moves to ReactiveUI.Binding too, so views are found by generated code. View hosts, routing, activation and validation stay in ReactiveUI.

Upgrade in five steps

  1. Update the ReactiveUI packages. ReactiveUI brings ReactiveUI.Binding with it, and ReactiveUI.Wpf, ReactiveUI.WinForms and ReactiveUI.Maui bring the matching Binding platform package. You add no new package. Reference one ReactiveUI flavor, never both:
Your app usesReferenceIt brings
ReactiveUI.Primitives (the default)ReactiveUIReactiveUI.Binding
System.ReactiveReactiveUI.ReactiveReactiveUI.Binding.Reactive
  1. Build, and read the warnings. Every warning that starts with RXUIBIND names a call that gets no generated code. Such a call throws when it runs. The Unsafe calls section below and the analyzer messages list the fixes.

  2. Fix the calls the generator cannot read. Make private controls and nested types internal, or switch the call to its Unsafe twin.

  3. Check your own IReactiveObject types. A class that implements IReactiveObject by hand needs one change to raise PropertyChanged. Classes that derive from ReactiveObject need nothing. See Hand-written IReactiveObject types.

  4. Run your tests. Read Behavior that changed for the differences a test can catch, such as the thread a binding writes on.

Namespaces

The binding methods and their types now live in the ReactiveUI.Binding namespace (ReactiveUI.Binding.Reactive for the System.Reactive flavor). The ReactiveUI package adds that namespace to your project's global usings. With <ImplicitUsings>enable</ImplicitUsings>, your calls compile with no new using. Without implicit usings, add this line once:

global using ReactiveUI.Binding;

These types moved to ReactiveUI.Binding and keep their names. A file that names one of them needs the namespace:

AreaTypes
ViewsIViewFor, IViewFor<T>, IActivatableView
BindingsIReactiveBinding<TView, TValue>, ReactiveBinding<TView, TValue>, BindingDirection, TriggerUpdate
Observed changesIObservedChange<TSender, TValue>, ObservedChange<TSender, TValue>
Extension pointsICreatesObservableForProperty, ICreatesCommandBinding, IPropertyBindingHook, BindingAffinity
ConvertersIBindingTypeConverter, IBindingTypeConverter<TFrom, TTo>, IBindingFallbackConverter, ISetMethodBindingConverter, the standard converters and their registries
InteractionsInteraction<TInput, TOutput>, IInteraction<TInput, TOutput>, IInteractionContext<TInput, TOutput>, IOutputContext<TInput, TOutput>, UnhandledInteractionException<TInput, TOutput>
PropertiesObservableAsPropertyHelper<T> and ToProperty
View registrationViewContractAttribute, SingleInstanceViewAttribute, ExcludeFromViewRegistrationAttribute
View locationIViewLocator, DefaultViewLocator, ViewLocator, ViewMappingBuilder, ViewLocatorNotFoundException

ObservableForProperty lives in ReactiveUI.Binding.ObservableForProperty. The ReactiveUI package imports that namespace too. It reads its path with reflection, so it is not safe to trim. For changes only, without the current value, WhenAnyValue(x => x.Name).Skip(1) is generated at compile time instead.

A view's WhenActivated(block) finds the view's ViewModel with reflection, so it is not safe to trim either. Pass the view's own WhenAnyValue(x => x.ViewModel) as the second argument, and it activates the view model without reflection:

this.WhenActivated(
    disposables => disposables(this.Bind(ViewModel, x => x.Name, v => v.NameBox.Text)),
    this.WhenAnyValue(x => x.ViewModel));

View location

The view hosts (ViewModelViewHost and RoutedViewHost on every platform) now use ReactiveUI.Binding's view locator. Its source generator finds each IViewFor<T> in your app at compile time, so a view is found without registering it and without reflection. For a view class the generator found, a registration in the service locator still wins over the generated lookup.

BeforeNow
ViewLocator.CurrentViewLocator.GetCurrent()
IViewLocator.ResolveView<T>(string? contract) and ResolveView<T>()The same, as extension methods on IViewLocator
IViewLocator.ResolveView(object? viewModel, string? contract)Checks the generated lookup and the Map entries only, so it is safe to trim. ResolveViewUnsafe(viewModel, contract) also asks the service locator
new ViewMappingBuilder(locator)locator.CreateMappingBuilder()
DefaultViewLocator.Map<TViewModel, TView>() chainedMap returns nothing; chain on CreateMappingBuilder() instead

A custom IViewLocator implements three methods. Keep ResolveView(object?, string?) free of any step that closes a generic type over the view model's run-time type, because the default view hosts call it. Put that step in ResolveViewUnsafe:

public IViewFor? ResolveView<TViewModel>(TViewModel viewModel, string? contract)
    where TViewModel : class;

public IViewFor? ResolveView(object? viewModel, string? contract);

[RequiresDynamicCode("Resolves a view from the view model's runtime type.")]
public IViewFor? ResolveViewUnsafe(object? viewModel, string? contract);

Views registered only in the service locator

The default view hosts find a view in two steps: the generated lookup, then the views you add with Map. They do not ask the service locator for IViewFor<T> of a view model's run-time type. So a view that only the service locator knows, such as one from a library built without the generator, is not found by ViewModelViewHost, RoutedViewHost, ViewModelControlHost, RoutedControlHost or the template AutoDataTemplateBindingHook assigns. A view registered under a contract that no [ViewContract] attribute names is in the same position. Pick one fix per view:

  • Bridge the registration, and keep the default host. One call while the app starts adds a Map entry whose view comes from the service locator. It stays safe to trim and to publish with Native AOT:

    locator.CreateMappingBuilder().MapFromServiceLocator<TViewModel, IViewFor<TViewModel>>();
    
  • Or map the view directly with locator.Map<TViewModel, TView>(), or mark the view class with [ViewContract] so the generator files it under its contract.

  • Or swap the host for its Unsafe twin: ViewModelViewHostUnsafe, RoutedViewHostUnsafe, ViewModelControlHostUnsafe, RoutedControlHostUnsafe or AutoDataTemplateBindingHookUnsafe. A twin also asks the service locator. It carries [RequiresDynamicCode], so a Native AOT build warns where you use it.

The default hosts carry no trim or AOT attributes, so you can remove any warning suppressions you added for them. View location compares the two lookups, and each platform page shows its twins.

Android auto-wireup constructors

LayoutViewHost and ReactiveViewHost<TViewModel> wire their child controls without reflection. The constructor that takes performAutoWireup and resolveStrategy belongs to LayoutViewHostUnsafe and ReactiveViewHostUnsafe<TViewModel>. Derive from the Unsafe type to keep auto-wireup, or wire the controls in the bind callback constructor. Android shows both.

The view without a contract answers only a request without a contract. A request for a contract that has no view finds nothing, and the host's ContractFallbackByPass decides whether it then asks for the view without a contract.

Calls the generator cannot read

The generator writes code for a call when each property is picked by an inline lambda over a path it can read. A path is the chain of members in the lambda, such as x => x.Customer.Name. A path can go through properties and through instance fields, such as controls named in XAML:

disposables(this.Bind(ViewModel, x => x.NewTitle, v => v.NewTitleBox.Text));

Every binding method has an Unsafe twin with the same parameters, such as BindUnsafe and WhenAnyValueUnsafe. An Unsafe twin reads the path with reflection while your app runs. It works for any path, but trimming can remove the members it looks for, so it carries [RequiresUnreferencedCode]. Prefer the generated call, and use the twin only where the generator cannot help.

Your callWhat to do
The path goes through an indexer or a method, such as x => x.Items[0].LengthCall the Unsafe twin
The call sits in a generic method, and its types come from type parametersCall the Unsafe twin
A type in the call is a private or protected nested classMake the class internal, or call the Unsafe twin
A control field is privateMake it internal, as below
The path is an Expression you build at run timeCall WhenAnyDynamic, which always uses reflection
The binding needs signalViewUpdate or TriggerUpdateCall BindUnsafe, the only overload that has them

WinForms designer fields and MAUI x:Name fields are private by default. Make a MAUI field internal in XAML:

<Entry x:Name="NameBox" x:FieldModifier="internal" />

A field raises no change notification, so the binding reads it once. The rest of the path is watched as usual. A readonly field can sit along a path, but not at the end of a path that a binding writes to.

Unsafe twins and the runtime fallback shows every twin with an example.

Triggered bindings

BindUnsafe takes an update stream where the plain, generated Bind does not: a stream whose values are ignored, used only to say when a two-way binding copies one direction. commit fires when the user commits the text, so the edit reaches the view model only then, not on every keystroke:

Signal<EventArgs> commit = new();

using (view.BindUnsafe(viewModel, x => x.FilterText, v => v.FilterTextBox.Text, commit))
{
    view.FilterTextBox.Text = "new text";
    commit.OnNext(EventArgs.Empty); // the view model sees the edit only now
}

TriggerUpdate names the direction the stream drives, as the last argument after the stream:

  • TriggerUpdate.ViewToViewModel, the default: the stream replaces the view's own change notifications. Editing the view writes to the view model only when the stream fires.
  • TriggerUpdate.ViewModelToView: the stream decides when the view model writes to the view. The view's own edits still write to the view model at once.

Unsafe twins and the runtime fallback covers every overload, including the ones that take conversion lambdas.

A conversion lambda always returns a value, and the binding writes it. ReactiveUI used to skip the write when a converter refused a value, such as "fa0" typed into a number box. To keep that, return the value the other side already holds. The binding sees an equal value and writes nothing.

ToProperty and ObservableAsPropertyHelper

ToProperty is generated. Name the property with a lambda, x => x.Total, or with nameof(Total). Any other form, such as an indexer or a computed string, gets no generated code, and the analyzer reports RXUIBIND013. That call throws when it runs. On a ReactiveObject, the generated code raises PropertyChanging and PropertyChanged through ReactiveUI's own notification state, so SuppressChangeNotifications and DelayChangeNotifications work as before.

ObservableAsPropertyHelper<T> keeps its members: Value, IsSubscribed, ThrownExceptions and Default(). One thing changed. When the source stream fails and nothing subscribes to ThrownExceptions, the helper throws the error on the thread that produced it. It no longer goes to RxState.DefaultExceptionHandler. Subscribe to ThrownExceptions if you relied on the handler.

Properties backed by observables covers ToProperty in full.

Views follow their view model

this.Bind(ViewModel, ...), OneWayBind, BindCommand and BindInteraction accept a nullable view model, so they compile when your view's ViewModel property is nullable. The binding follows view.ViewModel:

  • A binding made before the view has a view model waits for one.
  • Setting a new view model moves the binding to it.
  • While a parent on the path is null, such as x => x.Customer!.Name with no Customer, the view keeps its value. Nothing is written until the path is whole again.

The view model you pass in decides the types of the binding. The binding itself reads view.ViewModel. A view that implements only the non-generic IViewFor binds the view model you pass in. This works the same way for generated calls and Unsafe twins.

Hand-written IReactiveObject types

The generated code watches PropertyChanged. A class that implements IReactiveObject itself raises that event only after it calls SubscribePropertyChangedEvents. ReactiveUI's old observation read the Changed stream instead, so it never needed the call. Call it from the event's add accessor:

public event PropertyChangedEventHandler? PropertyChanged
{
    add
    {
        this.SubscribePropertyChangedEvents();
        _propertyChanged += value;
    }

    remove => _propertyChanged -= value;
}

Do the same with SubscribePropertyChangingEvents for PropertyChanging. ReactiveUI's own view hosts and platform base classes already do this.

Interactions

Use ReactiveUI 25.0.1 or later. It brings ReactiveUI.Binding 8.6.0, which lets you ask an interaction as an observable again and run its handlers on a scheduler.

Pick the call that fits your code:

You holdCallIt returns
Interaction<TInput, TOutput>Handle(input)Task<TOutput>
Interaction<TInput, TOutput>WhenHandled(input)IObservable<TOutput>
IInteraction<TInput, TOutput>Handle(input)IObservable<TOutput>

Interaction<TInput, TOutput>.Handle returns a Task<TOutput>. Await it:

if (!await ConfirmDelete.Handle(item))
{
    return false;
}

Code that subscribed to Handle, or used it inside an observable chain, calls WhenHandled instead. It can also keep calling Handle through an IInteraction<TInput, TOutput> variable or property:

ConfirmDelete.WhenHandled(item)
    .Where(static confirmed => confirmed)
    .Subscribe(_ => Items.Remove(item));

WhenHandled returns a cold observable. Nothing runs until you subscribe, and each subscription asks the question once. The observable fails with UnhandledInteractionException<TInput, TOutput> when no handler sets an output. Disposing the subscription drops the answer, but a handler that has already started still finishes.

A class that implements IInteraction<TInput, TOutput> itself must implement Handle(TInput) and return an IObservable<TOutput>. Classes that use or derive from Interaction<TInput, TOutput> need nothing.

To run every handler on the UI thread, pass the main thread's scheduler to the constructor. A handler that opens a dialog then runs on the right thread, whichever thread asked the question:

public Interaction<Item, bool> ConfirmDelete { get; } = new(RxSchedulers.MainThreadScheduler);

The constructor takes an ISequencer, or an IScheduler in the System.Reactive flavor. Without one, or with null, handlers run on the thread that asked.

A handler receives an IInteractionContext<TInput, TOutput> from ReactiveUI.Binding. Register your handlers against that type.

Behavior that changed

Every binding writes on the view's thread

Each binding moves its writes onto the thread that owns the view, on every platform. ReactiveUI used to do this only for WPF two-way Bind and for command swaps. A value set on another thread reaches the view one turn of the message loop later. A test that sets a value and checks the view at once may need to let the UI thread run first. Set BindingSchedulers.MainThread when you want every binding to write on a sequencer you choose. A sequencer is the object that decides which thread runs a piece of work.

A binding to a WPF, WinForms or MAUI object needs that platform's Binding package to move its writes. The ReactiveUI platform packages bring it. Without it, warning RXUIBIND017 reports the call.

A burst of changes writes once

A binding writes only the latest value. When a property changes several times before the binding can write, the values in between are skipped, and the binding's change stream skips them too.

Observations are delivered one at a time

A property observation delivers each change on the thread that raised it, one change at a time. When two threads change the property together, the second thread waits up to 20 milliseconds. After that it hands its change to the thread that is already delivering, and moves on. After-change observations then skip to the latest value. Before-change observations, such as WhenChanging, keep every change in order. Your subscriber runs without a lock held, so it can take its own locks safely.

Converters are picked by the declared types

A binding picks its converter from the types of the two properties as they are declared. ReactiveUI's builder registers the standard converters. The old fallback through TypeDescriptor is gone, so register a converter for any other pair. When nothing is registered and the value already has the target type, it passes through unchanged. For example, a string held in an object-typed SelectedItem binds to a string property.

Missing change notifications are found at build time

ReactiveUI logged a warning when your app ran into a property with no change notification. The analyzer now reports it as RXUIBIND010 when you build. The binding reads such a property once, as before. If your project treats warnings as errors and a test model raises no notification on purpose, make the model raise PropertyChanged rather than turning the warning off.

Custom observation providers compete by score

A custom ICreatesObservableForProperty handles a property only when its score is higher than the generated code's. The generated code wins a tie. Call ReactiveUI.Binding.Fallback.ObservationAffinityChecker.Refresh() after you change registrations. A custom provider still has to handle trimming for itself.

Removed types

RemovedUse instead
ReactiveUI's WhenAny*, ObservableForProperty, Bind, OneWayBind, BindTo, BindCommand, InvokeCommand and BindInteraction extension methodsThe same names from ReactiveUI.Binding, or their Unsafe twins
PropertyBinderImplementation, CommandBinderImplementation, InteractionBinderImplementation and their interfacesThe extension methods
Reflection: Rewrite, ExpressionToPropertyNames, GetValueFetcherForProperty, GetValueFetcherOrThrow, GetValueSetterForProperty, GetValueSetterOrThrow, TryGetValueForPropertyChain, TryGetAllValuesForPropertyChain and TrySetValueToPropertyChainThe same methods on ReactiveUI.Binding.Expressions.Reflection
Reflection.ReallyFindType, GetEventArgsTypeForEvent, ThrowIfMethodsNotOverloaded and ViewModelWhenAnyValueNothing. They served ReactiveUI's own engine
ExpressionMixins, ReflectionMixins, ExpressionRewriterReactiveUI.Binding.Expressions.ExpressionMixins
INPCObservableForProperty, POCOObservableForProperty, IROObservableForProperty and the providers for KVO, UIKit, AppKit, Android widgets, WinForms, WPF and WinUINothing. The generated code covers them. A custom provider still registers as ICreatesObservableForProperty
CreatesCommandBindingViaEvent, CreatesCommandBindingViaCommandParameter and the WinForms, Android, UIKit and AppKit command bindersNothing. The generated code covers them
The Apple NSDate convertersNothing. The generated code converts NSDate
ComponentModelFallbackConverterA converter you register for the pair
ReactiveUI's IViewLocator, DefaultViewLocator, ViewLocator, ViewMappingBuilder and ViewLocatorNotFoundExceptionThe same names from ReactiveUI.Binding. See View location

ReactiveUI.SourceGenerators

ReactiveUI.SourceGenerators no longer writes the two things ReactiveUI.Binding now provides: [ObservableAsProperty] and view registration. [Reactive], [ReactiveCommand], [IViewFor] and the other attributes work as before.

Removed from ReactiveUI.SourceGeneratorsUse instead
[ObservableAsProperty] on a field, a method or an IObservable<T> property, and InitializeOAPH()ReactiveUI.Binding's [ObservableAsProperty] on a partial property, assigned with ToProperty
The ReadOnly, UseProtected, PropertyName and InitialValue options of [ObservableAsProperty]Declare the property with the name and access you want. Pass initialValue: to ToProperty
RegisterViewsForViewModelsSourceGenerated(), SplatRegistrationType, and the RegistrationType and ViewModelRegistrationType options of [IViewFor]The generated view lookup. List IViewFor<T> on the view's declaration
RXUISG0014, RXUISG0017 and the RXUISPR0002 suppressionNothing. They only applied to [ObservableAsProperty]

RXUISG0012 and RXUISG0013 remain. They now report an attribute that any member forwards to its generated member with a [property:] or [field:] target, when the attribute's type or arguments are not valid.

Move a property to ReactiveUI.Binding's attribute

Declare the property yourself, as partial and get-only, and mark it [ObservableAsProperty]. The generator writes the property body and a field named _{name}Helper. You assign that field with ToProperty, as before, naming the property with a lambda or with nameof(...), and passing initialValue: for the field's old default. Partial properties need C# 13 or later. Below C# 13, write the helper yourself: a readonly ObservableAsPropertyHelper<T> _{name}Helper field and a property that reads its Value. Properties backed by observables covers ToProperty and shows both forms, and Source generators covers ReactiveUI.SourceGenerators' own attributes.

Register views without the Splat options

ReactiveUI.Binding's generator adds every class whose declaration implements IViewFor<T> to the generated view lookup. One source generator cannot see the code another one writes, so the lookup cannot see the interface that ReactiveUI.SourceGenerators' [IViewFor] attribute adds. List the interface on the class yourself; the members [IViewFor] generates still implement it. Remove the call to RegisterViewsForViewModelsSourceGenerated(), since the generated lookup replaces it. If the attribute's ViewModelRegistrationType option registered the view model as well, register it yourself instead, with RegisterViewModel<TViewModel>() on the builder or with AppLocator.CurrentMutable.RegisterLazySingleton.

A view registered in the service locator still wins over the generated lookup, so you can keep registering a view by hand, for example one whose constructor takes arguments. Views shows how, and Source generators covers [IViewFor] in full.

Build requirements

  • Compiler: Roslyn 4.8 or newer, which ships with Visual Studio 2022 17.8 and the .NET 8 SDK. An older compiler stops the build with RXUIBIND100.
  • Interceptors: with Roslyn 4.13 or newer and C# 11 or later, the generator replaces each call in place. With an older compiler or language version it writes overloads instead, and your code does not change either way. Choose how calls reach generated code explains the difference. The generated code compiles as C# 7.3 or later, and each project's generated code lives in a namespace of its own.
  • Trimming and NativeAOT: generated code is safe to trim. The Unsafe twins and WhenAnyDynamic are not, and a PublishAot build reports every place that calls them.

Where to go next