Skip to content

Migrating to Termina 0.7.0

Termina 0.7.0 introduces breaking changes as part of the migration from System.Reactive to R3 and the removal of the [Reactive] source generator in favor of explicit ReactiveProperty<T>. This guide covers everything you need to update.

Overview of Changes

AreaBefore (0.6.x)After (0.7.0)
Reactive frameworkSystem.Reactive (Rx.NET)R3
ViewModel state[Reactive] private int _count;ReactiveProperty<int> Count { get; } = new(0);
Observable typeIObservable<T>Observable<T>
Property accessCount++Count.Value++
Page bindingCountChanged.Select(...)Count.Select(...)
Backing storeBehaviorSubject<T>ReactiveProperty<T>
Class declarationpublic partial class MyVMpublic class MyVM (no partial needed)

1. Package Changes

Remove System.Reactive and add R3:

xml
<!-- Before -->
<PackageReference Include="System.Reactive" Version="6.x" />

<!-- After -->
<PackageReference Include="R3" Version="1.3.0" />

TIP

Termina 0.7.0 brings R3 as a transitive dependency, so you may not need to add it explicitly unless you use R3 operators directly.

2. Import Changes

Replace System.Reactive imports with R3:

csharp
// Before
using System.Reactive;
using System.Reactive.Linq;
using System.Reactive.Subjects;

// After
using R3;

3. ViewModel Migration: [Reactive] → ReactiveProperty<T>

The [Reactive] source generator has been removed. Use ReactiveProperty<T> directly.

Before

csharp
public partial class CounterViewModel : ReactiveViewModel
{
    [Reactive] private int _count;
    [Reactive] private string _status = "Ready";

    private void Increment()
    {
        Count++;
        Status = $"Count: {Count}";
    }
}

After

csharp
public class CounterViewModel : ReactiveViewModel
{
    public ReactiveProperty<int> Count { get; } = new(0);
    public ReactiveProperty<string> Status { get; } = new("Ready");

    private void Increment()
    {
        Count.Value++;
        Status.Value = $"Count: {Count.Value}";
    }

    public override void Dispose()
    {
        Count.Dispose();
        Status.Dispose();
        base.Dispose();
    }
}

Key differences

Aspect[Reactive] (old)ReactiveProperty<T> (new)
Declaration[Reactive] private int _count;public ReactiveProperty<int> Count { get; } = new(0);
Read valueCountCount.Value
Write valueCount = 5Count.Value = 5
SubscribeCountChanged.Select(...)Count.Select(...)
Class modifierpartial requiredNot required
DisposeAuto-generatedManual Dispose() override required
DistinctUntilChangedNot built-inBuilt-in (won't emit duplicate values)

Important

You must dispose all ReactiveProperty<T> instances. Add a Dispose() override to your ViewModel that disposes each property before calling base.Dispose().

4. Property Access Changes

Every direct property read/write must go through .Value:

csharp
// Before
Count++;
Status = "Done";
if (Count > 10) { ... }

// After
Count.Value++;
Status.Value = "Done";
if (Count.Value > 10) { ... }

5. Page Subscription Changes

ReactiveProperty<T> is itself an Observable<T>, so you subscribe directly to the property instead of a separate *Changed observable:

csharp
// Before
ViewModel.CountChanged
    .Select(count => new TextNode($"Count: {count}"))
    .AsLayout()

// After
ViewModel.Count
    .Select<int, ILayoutNode>(count => new TextNode($"Count: {count}"))
    .AsLayout()

TIP

Note the explicit type parameters on Select<int, ILayoutNode>. R3 sometimes needs explicit type parameters where System.Reactive could infer them.

6. Observable Operator Changes

OfType

R3's OfType requires both source and target type parameters:

csharp
// Before
Input.OfType<KeyPressed>()

// After
Input.OfType<IInputEvent, KeyPressed>()

Select

R3's Select may need explicit type parameters when inference fails:

csharp
// Before
observable.Select(x => new TextNode($"{x}")).AsLayout()

// After
observable.Select<int, ILayoutNode>(x => new TextNode($"{x}")).AsLayout()

CombineLatest

csharp
// Before
Observable.CombineLatest(
    ViewModel.UserNameChanged,
    ViewModel.IsLoggedInChanged,
    (name, loggedIn) => ...)

// After
Observable.CombineLatest(
    ViewModel.UserName,
    ViewModel.IsLoggedIn,
    (name, loggedIn) => ...)

7. StartWith → Removal or Prepend

StartWith does not exist in R3. The replacement depends on the source type:

ReactiveProperty<T>: Remove StartWith entirely

ReactiveProperty<T> already emits its current value to new subscribers, so StartWith is unnecessary:

csharp
// Before
ViewModel.StateChanged
    .StartWith(ViewModel.State)
    .Select(state => BuildLayout(state))
    .AsLayout()

// After — ReactiveProperty replays current value automatically
ViewModel.State
    .Select(state => BuildLayout(state))
    .AsLayout()

Subject<T>: Use Prepend

For Subject<T> (which does not replay), use Prepend to emit an initial value:

csharp
// Before
_selectionChanged.StartWith((ILayoutNode?)null)

// After
_selectionChanged.Prepend((ILayoutNode?)null)

8. BehaviorSubject → ReactiveProperty

If you used BehaviorSubject<T> directly (outside of [Reactive]), migrate to ReactiveProperty<T>:

csharp
// Before
private readonly BehaviorSubject<string> _status = new("Ready");
public IObservable<string> Status => _status.AsObservable();
public string CurrentStatus => _status.Value;

// Update:
_status.OnNext("Processing");

// After
public ReactiveProperty<string> Status { get; } = new("Ready");

// Update:
Status.Value = "Processing";

// ReactiveProperty IS Observable<T>, no wrapper needed

ReactiveProperty<T> replaces BehaviorSubject<T> for all state that needs both read access and subscriptions.

9. Subject<T> Migration

Subject<T> exists in both System.Reactive and R3. Just change the import:

csharp
// Before
using System.Reactive.Subjects;
private readonly Subject<Unit> _shutdown = new();
public IObservable<Unit> ShutdownRequested => _shutdown.AsObservable();

// After
using R3;
private readonly Subject<Unit> _shutdown = new();
public Observable<Unit> ShutdownRequested => _shutdown;

Note: In R3, Subject<T> is already Observable<T>, so .AsObservable() is not needed (though it still works).

10. Subscribe Callback Changes

R3 changes the signature of Subscribe callbacks:

csharp
// Before (System.Reactive)
observable.Subscribe(
    onNext: value => { ... },
    onError: error => { ... },
    onCompleted: () => { ... });

// After (R3)
observable.Subscribe(
    onNext: value => { ... },
    onErrorResume: error => { ... },  // renamed
    onCompleted: result => { ... });  // takes Result parameter

For simple subscriptions with just onNext, the API is the same:

csharp
// Works in both
observable.Subscribe(value => HandleValue(value));

Two-Argument Subscribe Trap

The two-argument Subscribe(onNext, second) changed semantics silently. In System.Reactive the second argument was onError. In R3 the second argument is onCompleted (taking Result, not Exception):

csharp
// System.Reactive — second arg is onError
observable.Subscribe(_ => { }, ex => Log(ex));

// R3 — second arg is onCompleted (takes Result, not Exception)
// To capture errors, use the three-argument form:
observable.Subscribe(_ => { }, ex => Log(ex), _ => { });

This won't just fail to compile — it will give a confusing cannot convert from 'R3.Result' to 'System.Exception' error. Always use the three-argument form when you need error handling.

11. Timer Changes

Replace System.Timers.Timer and System.Threading.Timer with Observable.Interval and TimeProvider:

csharp
// Before
private readonly Timer _timer = new(80);
_timer.Elapsed += OnTick;
_timer.Start();

// After
Observable.Interval(TimeSpan.FromMilliseconds(80), timeProvider ?? TimeProvider.System)
    .Subscribe(_ => OnTick())
    .DisposeWith(Subscriptions);

Components that use timers now accept an optional TimeProvider parameter for testable timing. Use FakeTimeProvider in tests for deterministic behavior.

12. Custom Node Render Methods

If you have custom LayoutNode subclasses, update Render methods to use CreateSubContext:

csharp
// Before (may render at wrong coordinates)
public override void Render(IRenderContext context, Rect bounds)
{
    context.DrawText(0, 0, "Hello");
}

// After (correct coordinate translation)
public override void Render(IRenderContext context, Rect bounds)
{
    var sub = context.CreateSubContext(bounds);
    sub.DrawText(0, 0, "Hello");
}

This ensures content renders at the correct position within the layout tree.

Migration Checklist

  • [ ] Replace System.Reactive package with R3
  • [ ] Update all using statements: System.Reactive.*R3
  • [ ] Convert [Reactive] fields to ReactiveProperty<T> properties
  • [ ] Remove partial modifier from ViewModel classes (if only needed for [Reactive])
  • [ ] Update all property access: PropProp.Value
  • [ ] Update page bindings: PropChangedProp
  • [ ] Remove .StartWith() on ReactiveProperty<T> (replays automatically); use .Prepend() on Subject<T>
  • [ ] Add explicit type params to OfType<TSrc, TDest>() and Select<TIn, TOut>()
  • [ ] Convert BehaviorSubject<T> to ReactiveProperty<T>
  • [ ] Update Subject<T> imports and exposed types (IObservable<T>Observable<T>)
  • [ ] Update Subscribe callbacks: onErroronErrorResume, onCompleted takes Result
  • [ ] Add Dispose() overrides disposing all ReactiveProperty<T> instances
  • [ ] Replace timers with Observable.Interval + TimeProvider
  • [ ] Update custom LayoutNode.Render() methods to use CreateSubContext
  • [ ] Verify build compiles cleanly
  • [ ] Run all tests

Released under the Apache 2.0 License.