Migrating from R3 / UniRx to Onity.Reactive
Onity.Reactive is a push-based, hot-by-default reactive layer with an R3-shaped vocabulary: Subject<T>, ReactiveProperty<T>, and operators like Where/Select/Merge/CombineLatest/Scan. The public stream contract is IOnityObservable<T> (R3’s Observable<T>), and Subject<T>, ReactiveProperty<T>, every operator, and broker.Observe<T>() all speak it, so events and state share one operator surface. Three behavioral differences matter most: trailing-edge throttle is explicit as ThrottleLast while leading-edge cool-down is Throttle; there is no Publish/Share/RefCount (multicast is served directly by Subject<T>); and lifetime is explicit — every Subscribe returns an IDisposable you must scope with AddTo(this) (Unity Component/Behaviour) or AddTo(compositeDisposable) (plain C#), with no AddTo(GameObject) overload and no implicit ref-counting. Time operators take an optional OnityTimeProvider (deterministic in tests). Every mapping below is verified against the shipped Onity public API (Onity.Reactive, Onity.Unity.Reactive).
Primitives
| R3 / UniRx | Onity | Notes |
|---|---|---|
new Subject<T>() | new Subject<T>() (Onity.Reactive) | Multicast source. OnNext is 0-alloc steady-state; OnNext/Subscribe after Dispose() throw ObjectDisposedException. |
subject.OnNext(v) | subject.OnNext(v) | Same. |
new ReactiveProperty<T>(initial) | new ReactiveProperty<T>(initial) | DistinctUntilChanged is built in (default comparer). Subscribing emits the current value first. |
rp.Value = x | rp.Value = x | Notifies only if changed. |
| (R3 has no bool-returning set) | rp.SetValue(x) → bool | Sets and returns whether the value actually changed. |
rp.Subscribe(...) (BehaviorSubject-style replay) | rp.Subscribe(...) | Emits current value, then each real change. Use rp.Subscribe(onNext, emitCurrentValue: false) to skip the initial emit. |
IReadOnlyReactiveProperty<T> | IReadOnlyReactiveProperty<T> | Read-only facade (Value + Subscribe(..., emitCurrentValue = true)). Note: it does not itself extend IOnityObservable<T>. |
new CompositeDisposable() | new CompositeDisposable() | Lifetime bag. Add/Remove/Clear/Count/Dispose. (Not an ICollection<IDisposable>.) |
Observable.FromEvent<T>(add, remove) | OnityObservable.FromEvent<T>(add, remove) | Factory. |
Observable.Return(v) | OnityObservable.Return<T>(v) | Factory. |
Observable.Empty<T>() | OnityObservable.Empty<T>() | Factory. |
Observable<T> (single observable type) | IOnityObservable<T> | The one public stream contract. (Observer<T> is delegate void Observer<T>(T value); the Subscribe(Action<T>) you normally write is an extension wrapping it.) |
Synchronous operators (OnityObservableExtensions)
All return IOnityObservable<T> and allocate only at subscribe time (0 alloc per emitted value).
| R3 / UniRx | Onity | Notes |
|---|---|---|
Where(predicate) | Where(Predicate<T>) | |
Select(selector) | Select(Func<TSource, TResult>) | |
DistinctUntilChanged() | DistinctUntilChanged(IEqualityComparer<T> = null) | |
Skip(n) / SkipWhile(p) | Skip(int) / SkipWhile(Predicate<T>) | Negative count throws ArgumentOutOfRangeException. |
Take(n) / TakeWhile(p) | Take(int) / TakeWhile(Predicate<T>) | |
StartWith(v) | StartWith(T) | |
Scan(seed, accumulator) | Scan<TState>(TState seed, Func<TState, T, TState>) | Stateful fold; state lives in the wrapping observer. |
Pairwise() | Pairwise() → IOnityObservable<OnityPair<T>> | Emits OnityPair<T> with Previous/Current; skips the first value. |
Merge(a, b, …) | Merge(params IOnityObservable<T>[]) | |
CombineLatest(other, selector) | CombineLatest<T1, T2, TResult>(IOnityObservable<T2>, Func<T1, T2, TResult>) | 2-arity only today; 3-4 arity is planned, not shipped. |
Sample(sampler) | Sample<TSignal>(IOnityObservable<TSignal> signalSource) | |
Buffer(count) | Buffer(int count) → IOnityObservable<IReadOnlyList<T>> | Emits each full count-sized batch. |
Subscribe(onNext) | Subscribe(Action<T>) | Returns IDisposable. |
Subscribe(onNext, onError, onCompleted) | Subscribe(Action<T>, Action<Exception>, Action<OnityResult>) | Completion carries an OnityResult. |
TakeUntil(cancellationToken) | TakeUntilCancellation(CancellationToken) | Stop on a token. |
FirstAsync(ct) | FirstAsync(CancellationToken = default) → Task<T> | First value or OperationCanceledException. |
ForEachAsync / ToTask (on Unit stream) | ToTask(CancellationToken = default) (on IOnityObservable<Unit>) |
using Onity.Reactive;
hp.Where(v => v <= 0)
.Select(_ => "dead")
.Subscribe(msg => Debug.Log(msg))
.AddTo(this);
Async / time operators (OnityObservableAsyncExtensions)
Each takes an optional OnityTimeProvider (deterministic in tests; pass a Unity time provider in gameplay).
| R3 / UniRx | Onity | Notes |
|---|---|---|
Debounce(dueTime) | Debounce(TimeSpan dueTime, OnityTimeProvider = null) | Emit the LAST value after a quiet window. |
ThrottleLast(interval) | ThrottleLast(TimeSpan interval, OnityTimeProvider = null) | Emit the latest value once per interval. |
Throttle(dueTime) (leading edge) | Throttle(TimeSpan interval, OnityTimeProvider = null) | Emits the first value immediately, then ignores values until the interval elapses. |
| trailing throttle / sample latest | ThrottleLast(TimeSpan interval, OnityTimeProvider = null) | Emits the latest value once per interval. Use this when you want trailing/sampled behavior. |
Buffer(timeSpan) | Buffer(TimeSpan timeSpan, OnityTimeProvider = null) → IOnityObservable<IReadOnlyList<T>> | Emits accumulated values once per time window. |
TakeUntil(otherObservable) | TakeUntil(CancellationToken) / TakeUntil(Task) | Signal is a token or a task, not another observable. |
SelectAwait(async selector) | SelectAwait(Func<T, CancellationToken, ValueTask<TResult>>) | Sequential async projection. Resumes on a threadpool thread — follow it with ObserveOnMainThread() before any Subscribe that touches UnityEngine. |
WhereAwait(async predicate) | WhereAwait(Func<T, CancellationToken, ValueTask<bool>>) | Sequential async filter; same off-main-thread caveat — re-marshal with ObserveOnMainThread(). |
ObserveOn(scheduler) | ObserveOn(OnityFrameProvider) (Onity.Reactive) | Re-posts each value onto the provider’s frame loop (buffered, replayed on the next tick). Pass OnityFrameProviders.Update / FixedUpdate / LateUpdate. |
ObserveOn(ThreadPoolScheduler) / thread-pool scheduler hop | ObserveOnThreadPool() (Onity.Reactive) | Re-posts values onto a .NET thread-pool worker while preserving source order. |
Observable.Start / CPU work on thread pool | SelectOnThreadPool(selector, maxConcurrency) (Onity.Reactive) | Runs pure managed CPU-bound projection on the .NET thread pool. Results emit as workers finish when concurrency is greater than one. |
ObserveOnMainThreadDispatcher() | ObserveOnMainThread() / ObserveOnMainThread(OnityUnityFrameProvider) (Onity.Unity.Reactive) | Convenience hop onto the Unity Update loop (or a chosen phase). This is the documented re-marshal after SelectAwait/WhereAwait. |
SelectAwait/WhereAwait resume off the Unity main thread, so re-marshal before touching Unity API:
using Onity.Reactive; // SelectAwait, Subscribe
using Onity.Unity.Reactive; // ObserveOnMainThread, AddTo
m_requests
.SelectAwait((id, ct) => LoadProfileAsync(id, ct)) // runs on a threadpool thread
.ObserveOnMainThread() // hop back to the Update loop
.Subscribe(profile => m_nameLabel.text = profile.Name)
.AddTo(this);
For CPU-bound pure managed work that should run concurrently, use SelectOnThreadPool and then re-marshal before Unity API access:
m_damageEvents
.SelectOnThreadPool((damage, ct) => CalculateScoreDelta(damage), maxConcurrency: 4)
.ObserveOnMainThread()
.Subscribe(delta => m_scoreLabel.text = delta.ToString())
.AddTo(this);
Unity bridges — frame loops, timers, lifetime (Onity.Unity.Reactive)
| R3.Unity / UniRx | Onity | Notes |
|---|---|---|
Observable.EveryUpdate() | OnityUnityObservable.EveryUpdate() → IOnityObservable<Unit> | Shared singleton, pumped by a hidden DontDestroyOnLoad object. |
Observable.EveryFixedUpdate() / EveryLateUpdate() | OnityUnityObservable.EveryFixedUpdate() / EveryLateUpdate() | |
Observable.Timer(t) | OnityUnityObservable.Timer(float seconds, bool unscaled = false) | One Unit after the delay. |
Observable.Interval(t) | OnityUnityObservable.Interval(float seconds, bool unscaled = false) → IOnityObservable<int> | Tick index every interval. |
.Delay(t) | OnityUnityObservableExtensions.Delay<T>(seconds, useUnscaledTime = false) | |
.AddTo(this) (MonoBehaviour) | someDisposable.AddTo(this) | this is a Component. Disposes on Destroy (alias for TakeUntilDestroy). |
.AddTo(gameObject) | no AddTo(GameObject) | Divergence: lifetime helpers take Component/Behaviour only. Pass this from a MonoBehaviour. |
.TakeUntilDestroy(this) | someDisposable.TakeUntilDestroy(this) (Component) | Disposes on Destroy. |
.TakeUntilDisable(this) | someDisposable.TakeUntilDisable(this) (Behaviour) | Disposes on disable. |
.AddTo(compositeDisposable) | someDisposable.AddTo(compositeDisposable) (OnityDisposableExtensions) | For plain-C# owners. |
Lifetime helpers extend
IDisposable, notIOnityObservable<T>, so they go afterSubscribe(which returns theIDisposable), not on the observable:stream.Subscribe(...).AddTo(this);
Time providers (OnityTimeProviders)
Pass one into Debounce/ThrottleLast in gameplay so they honor Time.timeScale:
using Onity.Reactive;
using Onity.Unity.Reactive; // OnityTimeProviders
m_query
.Debounce(TimeSpan.FromMilliseconds(250), OnityTimeProviders.UpdateUnscaled)
.Subscribe(onSearch)
.AddTo(this);
Available: UpdateScaled/UpdateUnscaled/UpdateRealtime, FixedScaled/FixedUnscaled/FixedRealtime, LateScaled/LateUnscaled/LateRealtime. In EditMode tests, subclass the abstract OnityTimeProvider to drive delays deterministically (no public Manual/Fake provider ships yet).
Events as a stream (broker.Observe<T>())
R3 needs a manual adapter to turn a message bus into an observable; Onity’s messaging and reactive pillars share IOnityObservable<T>:
using Onity.Reactive; // Where, Select, Subscribe
using Onity.Unity.Messaging; // Observe<T> on IMessageBroker / OnityEventHub
using Onity.Unity.Reactive; // AddTo
broker.Observe<DamageEvent>()
.Where(e => e.Amount > 0)
.Select(e => e.Amount)
.Subscribe(amount => Debug.Log($"Took {amount}"))
.AddTo(this);
broker.Observe<T>(), subscriber.Observe<T>(), and OnityEventHub.Observe<T>() all return IOnityObservable<T> (the hub caches one stream per message type).
Errors
Onity.Reactive defines OnityReactiveException, but the shipped guard paths below throw standard .NET exceptions. Do not catch only the Onity-specific type; map your R3 error handling to the actual operation instead:
| Exception | Cause | Fix |
|---|---|---|
ObjectDisposedException | OnNext/Subscribe after Subject.Dispose() (also MessageChannel/MessageBroker). | Tie subscriptions to lifetime with AddTo; stop emitting to a disposed source. |
ArgumentNullException | A null source, handler, predicate, or selector passed to an operator / FromEvent / Subscribe. | Pass non-null delegates and sources. |
ArgumentOutOfRangeException | Negative count to Skip/Take/etc. | Pass a count >= 0. |
OperationCanceledException | The CancellationToken cancelled before the awaited value arrived in FirstAsync/ToTask/TakeUntil. Normal cancellation, not a bug. | Catch it where you start the async flow; do not treat as failure. |
Not supported — do this instead
These R3 / UniRx features are deliberate Onity non-goals. Do not call the R3 or UniRx API; use the Onity replacement.
| R3 / UniRx feature | Why it is a non-goal | Do this in Onity |
|---|---|---|
Hot/cold conversion Publish / Share / RefCount / Multicast | Implicit ref-counting contradicts the “one subscribe = one disposable” principle. | Multicast is already served by Subject<T> — subscribe a Subject<T> directly; share one instance via DI (BindInstance / BindInterfacesAndSelfTo). |
Cold factories Create / Defer / Never | Onity is hot-by-default; cold factories are deferred (some docs once claimed them — they are not implemented). | Drive a Subject<T> / ReactiveProperty<T> yourself, or use OnityObservable.Return/Empty/FromEvent. |
ObserveEveryValueChanged(poll) | Inherently a per-frame polling allocation/CPU pattern that conflicts with push-based + 0-alloc. | Hold the value in a ReactiveProperty<T> and subscribe, or expose it as a message and Observe<T>(). |
Window / Zip / Switch / Concat | Not shipped yet (planned). | Compose with shipped operators (Buffer/Scan/Pairwise/Merge/CombineLatest/Sample), or accumulate in a ReactiveProperty<T>. |
Error-flow Catch / Retry / Timeout | The model does not yet carry a rich OnError channel through operators. | Handle failures in the Subscribe(onNext, onError, onCompleted) overload, or guard inside the operator delegate. |
IObservable<T> (System.Reactive) compatibility adapters | Explicitly not shipped to avoid a third-party type leak. | Stay on IOnityObservable<T>; bridge events via Observe<T>(). |
| Job/Burst/DOTS parallel managed operator execution | Unity Job/Burst frame modes are frame boundaries, not a way to run managed observers or DI inside Burst. | Use SelectOnThreadPool for pure managed CPU work, ObserveOnThreadPool for ordered thread-pool hops, and ObserveOnMainThread before Unity API access. Keep Burst/DOTS work in blittable bridge modules. |
AddTo(gameObject) | Lifetime helpers extend IDisposable and take Component/Behaviour. | AddTo(this) / TakeUntilDestroy(this) (Component), TakeUntilDisable(this) (Behaviour), or AddTo(compositeDisposable). |