Introduction
There are many ways to handle events and state changes in Unity.
UpdateUnityEvent- C# events
Actionasync/await- UniTask
- UniRx
- R3
- custom event buses
- ScriptableObject-based events
For a small prototype, almost any of these can work.
The situation changes when a project has around ten programmers and multiple people touch the same UI, game flow, networking, effects, and loading code. Event handling quickly becomes one of the easiest places to lose control.
Typical problems look like this:
- Nobody knows where a subscription is created.
- Nobody knows when a subscription is disposed.
- Events are delivered to destroyed GameObjects.
-
Subjectturns into a global event bus. - UI updates are scattered across many classes.
- async code and input-spam prevention are mixed together.
- leaked subscriptions cause memory leaks or duplicated execution.
- only the people who already know Rx can read the code.
R3 is a modern Reactive Extensions implementation developed by Cysharp.
The Cysharp/R3 README describes R3 as a new future for dotnet/reactive and UniRx, with support for multiple platforms including Unity. The author also introduces R3 as a modern reimplementation of Reactive Extensions for C# in this article.
For Unity projects, you usually install both the core R3 package and R3.Unity. Unity support starts from Unity 2021.3.
In production, keep the versions of R3 and R3.Unity aligned. Updating only one side, or leaving one side behind, can cause API mismatches or dependency trouble. It is better to define the installation and update procedure as part of the project rules.
In Unity, UnityTimeProvider.Update and UnityFrameProvider.Update are set as defaults, so R3 fits naturally into Unity 6-era projects.
This article focuses on how to use R3 in a maintainable way in Unity 6 projects, and which patterns should be avoided in team development.
The target environment is roughly this:
- Unity 6 series
- around ten programmers
- long-term operation and maintenance
- UI, loading, networking, game flow, and effects touched by multiple people
- a mix of UniRx users and people new to reactive programming
- a need to care about GC, subscription leaks, and operational stability
This is not a full introduction to every R3 feature.
The goal is simpler: define practical rules that prevent R3 from breaking maintainability in team development.
Code samples omit common using directives such as using R3;, using UnityEngine;, R3.Unity uGUI extensions, TextMeshPro namespaces, and CancellationToken. Adjust them to match your UI package, DI container, and project structure.
The short version
If you use R3 in Unity, start with these rules:
- Use R3 for event wiring and state-change notification.
- Leave one-shot async operations to
async/await. - Expose
Observable<T>orReadOnlyReactiveProperty<T>. - Keep
Subject<T>and writableReactiveProperty<T>private. - Always tie subscriptions to a GameObject, Component, ViewModel, or another explicit owner.
- Use
SubscribeAwaitandAwaitOperationfor UI input control in the operational layer. - Use frame-based operators such as
IntervalFrame,DelayFrame, andEveryUpdateonly when the frame semantics matter. - Limit the R3 patterns allowed in the team.
- Do not turn everything into an Observable.
The most important point is this:
Do not turn R3 into a magical application-wide event system.
R3 is powerful. If everyone uses it freely, the project can quickly become a codebase where nobody knows where events come from or who reacts to them.
For maintainability, use R3 as a strong tool, but keep its scope controlled.
Where R3 works well
R3 is a good fit when you want to compose a stream of values or events.
For example:
- button clicks
- Toggle and Slider changes
- input events
- HP, stamina, currency, and other state-change notifications
- ViewModel-to-View updates
- loading progress
- busy/loading flags
- input-spam prevention
- delayed-by-frame behavior
- combining multiple events
- updating UI only when a value changes
On the other hand, not everything should be pushed into R3.
These are usually better left outside R3:
- simple sequential processing
- one-shot loading
- complex domain logic
- cutscene or effect sequences that are easier to read procedurally
- large amounts of per-frame logic
- the core of physics updates
- AI decision-making logic
- battle calculation logic
R3 is for observing, transforming, combining, and subscribing to value streams.
You do not need to convert every piece of code into an Observable.
Separate R3 from async/await
The first rule to decide is how R3 and async/await should share responsibility.
The Cysharp/R3 README follows the idea that one-shot asynchronous operations should generally be represented by ***Async methods returning Task<T>.
In Unity projects, I recommend this split.
Use async/await for these
- loading master data once
- loading one prefab through Addressables
- calling one API endpoint
- saving data once
- running initialization steps in order
- showing one dialog and waiting for its result
When using Addressables, waiting with async/await and releasing resources are separate responsibilities. The owner of the loaded resource still needs to own Addressables.Release, Addressables.ReleaseInstance, or the equivalent cleanup. This is similar to R3 subscription lifetime management: do not make ownership vague.
Use R3 for these
- handling repeated button clicks
- reflecting value changes into the UI
- deriving button interactability from multiple states
- showing and hiding loading UI from a busy flag
- controlling repeated input with
DroporSwitch - handling events based on frames, timers, or repeated intervals
For example, a loading operation itself can be plain async/await:
public async Task<PlayerMaster> LoadPlayerMasterAsync(CancellationToken ct)
{
var json = await _storage.LoadTextAsync("player_master.json", ct);
return JsonUtility.FromJson<PlayerMaster>(json);
}
This short example uses JsonUtility only to keep the sample simple. In real projects, choose a JSON library or custom loader based on your data format. JsonUtility has limitations such as dictionaries, top-level arrays, and null handling.
If you want to expose loading progress to the UI, R3 becomes useful:
public sealed class LoadingModel : IDisposable
{
private readonly ReactiveProperty<float> _progress = new(0f);
public ReadOnlyReactiveProperty<float> Progress => _progress;
public void SetProgress(float value)
{
_progress.Value = Mathf.Clamp01(value);
}
public void Dispose()
{
_progress.Dispose();
}
}
A class like LoadingModel implements IDisposable, so whoever creates it must also define when it is disposed. That owner might be a DI container, a LifetimeScope, a screen Presenter, or a MonoBehaviour.
The operation itself is async/await.
The state notification is R3.
This separation makes the code much easier to read in a team.
Publish R3 events on the Unity main thread by default
In Unity projects, it is safest to make this a team rule:
Subject.OnNext and ReactiveProperty<T>.Value updates should normally happen on the Unity main thread.
The Cysharp/R3 README explains that operator composition is designed to be thread-safe, but the values flowing through OnNext are expected to be published from a single thread. If Subject.OnNext is called from multiple threads, synchronization such as lock is required. ReactiveProperty also does not make simultaneous OnNext, Value updates, and Subscribe operations thread-safe.
A practical Unity rule looks like this:
// Example team policy
// - Update ReactiveProperty.Value on the Unity main thread.
// - Do not call Subject.OnNext directly from external SDK callbacks or background threads.
// - If a result comes from Task.Run, a custom thread, or an SDK callback,
// return to the main thread before notifying the UI or game layer when needed.
// - If multi-threaded publication is truly required, consider lock,
// Synchronize, SynchronizedReactiveProperty, or a dedicated queue.
Network callbacks and external SDK events do not always return on Unity's main thread.
This is not only about "UI must be updated on the main thread." It is also about keeping the R3 event stream itself on a predictable thread.
Keep ReactiveProperty private
ReactiveProperty<T> is one of the most useful R3 types for state.
It is also easy to misuse.
If you expose it directly as public, any caller can write to it.
Bad example:
public sealed class PlayerStatus
{
public ReactiveProperty<int> Hp { get; } = new(100);
}
Now any caller can do this:
playerStatus.Hp.Value = 999;
This is convenient in the short term, but once the game is in operation, it becomes hard to answer a basic question: "Who changed HP?"
For maintainability, keep writable ReactiveProperty<T> fields private and expose them as read-only properties.
public sealed class PlayerStatus : IDisposable
{
private readonly ReactiveProperty<int> _hp = new(100);
public ReadOnlyReactiveProperty<int> Hp => _hp;
public void Damage(int value)
{
if (value <= 0)
{
return;
}
_hp.Value = Mathf.Max(0, _hp.Value - value);
}
public void Heal(int value)
{
if (value <= 0)
{
return;
}
_hp.Value = Mathf.Min(100, _hp.Value + value);
}
public void Dispose()
{
_hp.Dispose();
}
}
This PlayerStatus also implements IDisposable, so the creator must dispose it. Tie it to the owner lifetime: screen, battle, scene, DI scope, or another explicit lifetime.
External code can subscribe to HP changes, but normal API usage cannot modify the value directly.
This is not perfect type-level security. Since the actual object is still a ReactiveProperty<T>, an abusive downcast can still mutate it. Make it part of the team rule that ReadOnlyReactiveProperty<T> must not be cast back to ReactiveProperty<T> for mutation.
_playerStatus.Hp
.Subscribe(hp => _hpText.text = hp.ToString())
.AddTo(this);
The basic pattern is this:
private readonly ReactiveProperty<T> _value = new(initialValue);
public ReadOnlyReactiveProperty<T> Value => _value;
This is especially important in Models and ViewModels.
R3 should make state flow easier to observe. It should not make state writable from anywhere.
Be even more careful with Subject
Subject<T> is convenient, but it is also one of the most dangerous R3 types.
A Subject<T> is an event publication endpoint. If it is public, anyone can call OnNext.
Bad example:
public sealed class BattleEvents
{
public Subject<int> OnDamage { get; } = new();
}
Now unrelated code can publish damage:
battleEvents.OnDamage.OnNext(999);
That breaks the meaning of the event source.
A more maintainable shape is to keep Subject<T> private and expose it as Observable<T>.
public sealed class BattleEvents : IDisposable
{
private readonly Subject<int> _damaged = new();
public Observable<int> Damaged => _damaged;
public void PublishDamage(int damage)
{
if (damage <= 0)
{
return;
}
_damaged.OnNext(damage);
}
public void Dispose()
{
_damaged.Dispose();
}
}
From the public API, other classes can treat it as subscription-only. Again, a type alone cannot prevent every abusive cast or rule violation. The important part is that the normal path does not expose the publication endpoint, and the team rule forbids casting it back to Subject to publish events.
_battleEvents.Damaged
.Subscribe(damage => ShowDamageEffect(damage))
.AddTo(this);
Now event publication is limited to the code path that can call PublishDamage.
That alone makes the event flow much easier to trace.
Do not create a global Subject-based event bus
A common failure after introducing R3 is a global event bus.
For example:
public static class GlobalEventBus
{
public static readonly Subject<object> Event = new();
}
Or even with separate event types:
public static class GameEvents
{
public static readonly Subject<PlayerDeadEvent> PlayerDead = new();
public static readonly Subject<ItemGotEvent> ItemGot = new();
public static readonly Subject<QuestCompletedEvent> QuestCompleted = new();
}
This can look useful in a small project.
As the team grows, the problems appear quickly:
- publishers are hard to trace
- subscribers are hard to trace
- event lifetime is vague
- subscriptions survive across scenes
- tests become harder
- event names keep increasing
- execution order becomes implicit
- the impact of specification changes becomes hard to predict
In a live game, someone may add an event later without noticing an existing subscriber, causing unexpected side effects.
If you use R3, keep events scoped as much as possible.
- only inside Battle
- only inside one UI screen
- only inside a ViewModel
- only inside one Feature
- only inside a Scene or LifetimeScope
If an event truly needs to be application-wide, design it as an explicit application service or event aggregation layer. Do not just place an R3 Subject globally and call it a day.
Every Subscribe must have a lifetime
The most important part of R3 usage is subscription disposal.
Subscribe creates a subscription.
If that subscription is never disposed, references can remain, events can be delivered to destroyed Views, and the same logic can run two or three times.
The Cysharp/R3 README also emphasizes that subscription management is a core Rx concern, and poor management can lead to memory leaks.
In Unity, the basic pattern is to tie subscriptions to a Component or GameObject.
OnClickAsObservable() and OnValueChangedAsObservable() in these samples are R3.Unity uGUI extension methods.
_button.OnClickAsObservable()
.Subscribe(_ => OnClick())
.AddTo(this);
This disposes the subscription according to the lifetime of this MonoBehaviour.
You can also tie it to a GameObject:
_observable
.Subscribe(x => Debug.Log(x))
.AddTo(gameObject);
The review rule is simple:
If you call
Subscribe, decide the lifetime on the same line or nearby.
Bad example:
void Start()
{
_playerStatus.Hp.Subscribe(hp =>
{
_hpText.text = hp.ToString();
});
}
This code does not show when the subscription is disposed.
It is also easy to miss in code review.
Better:
void Start()
{
_playerStatus.Hp
.Subscribe(hp =>
{
_hpText.text = hp.ToString();
})
.AddTo(this);
}
R3.Unity's AddTo(Component / GameObject) is designed to handle Unity-specific destruction issues by using ObservableDestroyTrigger, including cases where inactive GameObjects can make destruction handling awkward.
However, the lifetime here is basically destruction lifetime.
Do not assume AddTo(this) means the subscription stops when the UI is hidden, disabled, or switched to another tab.
SetActive(false), OnDisable, tab changes, and internal screen state changes are different lifetimes. Decide per screen whether hidden UI should keep receiving model updates. If you need subscriptions to stop on disable or rebinding, manage that separately with another DisposableBag, explicit re-subscription, TakeUntil, CancellationToken, or another lifecycle mechanism.
How to choose disposable containers
R3 gives you several ways to manage subscriptions.
For maintainability, keep the team rule simple.
AddTo(this)
Use this for the normal MonoBehaviour lifetime.
_observable
.Subscribe(OnValueChanged)
.AddTo(this);
This should be the default in Unity Views and Presenters.
DisposableBag
Use this when a normal C# class owns multiple subscriptions.
private DisposableBag _disposables;
public void Initialize()
{
_model.Hp
.Subscribe(UpdateHp)
.AddTo(ref _disposables);
_model.Mp
.Subscribe(UpdateMp)
.AddTo(ref _disposables);
}
public void Dispose()
{
_disposables.Dispose();
}
DisposableBag is a struct, so avoid accidental copies.
Hold it as a field and pass it by ref.
Disposable.Combine / Builder
When the number of subscriptions is fixed and small, you can build one disposable.
private IDisposable _disposable;
public void Initialize()
{
var builder = Disposable.CreateBuilder();
_model.Hp.Subscribe(UpdateHp).AddTo(ref builder);
_model.Mp.Subscribe(UpdateMp).AddTo(ref builder);
_disposable = builder.Build();
}
public void Dispose()
{
_disposable?.Dispose();
}
If the number of subscriptions is static, this keeps the code simple and avoids unnecessary dynamic management.
CompositeDisposable
Use this only when you need dynamic add/remove behavior.
private readonly CompositeDisposable _disposables = new();
public void Add(IDisposable disposable)
{
disposable.AddTo(_disposables);
}
public void Dispose()
{
_disposables.Dispose();
}
CompositeDisposable has more features, but that does not make it the default choice.
A practical team rule is:
-
AddTo(this)for MonoBehaviours. -
DisposableBagfor normal classes. -
Disposable.CreateBuilder()orDisposable.Combinefor a fixed small set. -
CompositeDisposableonly when removal or dynamic management is needed.
R3 is especially useful for UI
UI is one of the best places to use R3 in Unity.
UI often needs to update when state changes.
Here is a simple HP display:
public sealed class PlayerStatusView : MonoBehaviour
{
[SerializeField] private TextMeshProUGUI _hpText;
[SerializeField] private Slider _hpSlider;
private PlayerStatus _status;
public void Initialize(PlayerStatus status)
{
_status = status;
_status.Hp
.Subscribe(UpdateHp)
.AddTo(this);
}
private void UpdateHp(int hp)
{
_hpText.text = hp.ToString();
_hpSlider.value = hp;
}
}
This much can also be done with a normal event.
In this sample, _hpSlider.value = hp assumes Slider.maxValue matches the max HP. If your project uses normalized 0-1 slider values, convert the value in the View, such as hp / maxHp.
Also, this example assumes Initialize() is called only once. If prefabs are reused, screens are re-initialized, DI injection happens again, or tabs are rebound, dispose the previous subscriptions before subscribing again. AddTo(this) keeps subscriptions until the GameObject is destroyed, so it does not automatically remove subscriptions created by repeated initialization.
R3 becomes more useful when UI state depends on multiple values.
_canDecide = Observable
.CombineLatest(
_model.SelectedItem,
_model.HasEnoughGold,
_model.IsBusy,
(item, hasGold, isBusy) => item != null && hasGold && !isBusy)
.ToReadOnlyReactiveProperty();
// If this MonoBehaviour owns it:
_canDecide.AddTo(this);
// If a normal Presenter / ViewModel owns it:
// _canDecide.AddTo(ref _disposables);
ToReadOnlyReactiveProperty() creates an object that owns an upstream subscription. That generated ReadOnlyReactiveProperty also needs its lifetime managed.
In this sample, the MonoBehaviour owns _canDecide, so it uses AddTo(this). If a Presenter or ViewModel owns it, tie it to that class's DisposableBag instead.
The button can subscribe to it:
_canDecide
.Subscribe(canDecide => _decideButton.interactable = canDecide)
.AddTo(this);
This is where R3 shines: deriving UI state from multiple model states.
If the expression grows too long, move the logic into a named method:
private bool CanDecide(Item item, bool hasGold, bool isBusy)
{
return item != null && hasGold && !isBusy;
}
_canDecide = Observable
.CombineLatest(
_model.SelectedItem,
_model.HasEnoughGold,
_model.IsBusy,
CanDecide)
.ToReadOnlyReactiveProperty();
// If this MonoBehaviour owns it:
_canDecide.AddTo(this);
// If a normal Presenter / ViewModel owns it:
// _canDecide.AddTo(ref _disposables);
R3 chains are easy to read when they are short. When they become long, split them. The follow-up article covers more detailed rules for splitting complex chains.
Do not put heavy logic inside Subscribe
A common R3 mistake is putting too much logic inside Subscribe.
Bad example:
_button.OnClickAsObservable()
.Subscribe(_ =>
{
if (_model.SelectedItem == null)
{
_dialog.Show("Please select an item.");
return;
}
if (!_wallet.HasEnoughGold(_model.SelectedItem.Price))
{
_dialog.Show("Not enough gold.");
return;
}
_wallet.Consume(_model.SelectedItem.Price);
_inventory.Add(_model.SelectedItem);
_sound.Play(SE.Decide);
_analytics.Send("buy_item");
_screen.Close();
})
.AddTo(this);
This is easy to write at first, but it does not age well.
Use the R3 chain as the event entry point.
Move the actual logic to methods.
_button.OnClickAsObservable()
.Subscribe(_ => TryBuySelectedItem())
.AddTo(this);
private void TryBuySelectedItem()
{
if (_model.SelectedItem == null)
{
_dialog.Show("Please select an item.");
return;
}
if (!_wallet.HasEnoughGold(_model.SelectedItem.Price))
{
_dialog.Show("Not enough gold.");
return;
}
Buy(_model.SelectedItem);
}
private void Buy(Item item)
{
_wallet.Consume(item.Price);
_inventory.Add(item);
_sound.Play(SE.Decide);
_analytics.Send("buy_item");
_screen.Close();
}
R3 should express when something is called.
Game logic should remain in ordinary methods.
That separation makes code review much easier.
Summary
This article covered the basic design rules for using R3 in Unity 6-era team development.
The key idea is not to spread R3 everywhere just because it is convenient.
- Use R3 for event wiring and state-change notification.
- Leave one-shot async operations to
async/await. - Keep
ReactiveProperty<T>andSubject<T>private. - Expose
ReadOnlyReactiveProperty<T>orObservable<T>. - Always decide the lifetime when you call
Subscribe. - Remember that
AddTo(this)is destruction lifetime, not hidden/disabled lifetime. - If
Initialize()can run multiple times, dispose previous subscriptions before subscribing again. - Do not put heavy logic directly inside
Subscribe.
The next article will cover the operational side: SubscribeAwait, AwaitOperation, EveryUpdate, providers, Observable Tracker, forbidden patterns, and code review points.
Top comments (0)