DEV Community

GameDevToolLab
GameDevToolLab

Posted on

R3 Operation Rules for Unity 6: SubscribeAwait, EveryUpdate, and Patterns to Avoid

Introduction

In the previous article, I covered the basic design rules for using R3 in Unity 6 projects: how to expose ReactiveProperty<T> and Subject<T>, how to manage subscription lifetimes, and how to separate R3 from async/await.

This article continues from there and focuses on operation rules after introducing R3.

The target reader is a Unity team with around 10 programmers, where multiple people touch UI, networking, loading, presentation, and game flow. In solo development or short-lived prototypes, you may not need to be this strict. This article intentionally takes a conservative approach that prioritizes maintainability in long-running projects.

This is not an article about Unity 6-specific APIs. It is about how to operate R3 safely in Unity 6-era projects. Most of the ideas also apply to other Unity versions supported by R3.

The code samples omit common using declarations such as using R3;, using UnityEngine;, TextMeshPro, R3.Unity uGUI extensions, and CancellationToken. Methods such as OnClickAsObservable() and OnValueChangedAsObservable() assume R3.Unity uGUI extensions. In Unity projects, you need both the R3 core package and R3.Unity, and their versions should be aligned.

This article assumes R3 1.2.0 or later. In R3 1.2.0, the default value of cancelOnCompleted for SubscribeAwait, SelectAwait, WhereAwait, and ReactiveCommand was changed to false. When combining Take(1), disposal, completion, and async work, distinguish between stopping further input and canceling the currently running operation.

The three rules to take away are:

  • SubscribeAwait(Drop) prevents duplicate execution while an operation is running; it is not the same as simultaneous-input protection or one-shot control.
  • PlayerLoop/time-based streams such as EveryUpdate and Timer should make purpose, lifetime, and provider explicit.
  • Use R3 for notification and wiring; keep domain logic and important mutual exclusion readable as ordinary C#.

Use SubscribeAwait for repeated-click protection

A common UI problem is repeated clicks during a network request or loading operation.

R3 provides SubscribeAwait and SelectAwait. They let you specify how to handle the next event while an async operation is still running by using AwaitOperation.

If you want to ignore clicks while a request is running, use Drop.

_loginButton.OnClickAsObservable()
    .SubscribeAwait(
        async (_, ct) =>
        {
            await LoginAsync(ct);
        },
        AwaitOperation.Drop)
    .AddTo(this);
Enter fullscreen mode Exit fullscreen mode

Drop discards subsequent events that arrive while the async operation is running. It is useful for login buttons, purchase buttons, gacha buttons, and confirm buttons where duplicate execution during processing must be avoided.

However, Drop does not mean "execute only once forever." If the operation finishes quickly, the next click will be processed normally. If the dialog stays open after the operation, input can still be accepted again. If you want to accept only the first input for the whole screen or dialog session, use a separate mechanism such as Take(1), a confirmed flag, explicit disposal, or closing the screen.

For a search box where you want to cancel the previous request and process only the latest text, Switch is usually a better fit.

_searchInput.OnValueChangedAsObservable()
    .Skip(1) // Use this when initial-value search is not needed.
    .Debounce(TimeSpan.FromMilliseconds(300), UnityTimeProvider.Update)
    .SubscribeAwait(
        async (text, ct) =>
        {
            await SearchAsync(text, ct);
        },
        AwaitOperation.Switch)
    .AddTo(this);
Enter fullscreen mode Exit fullscreen mode

In R3.Unity, OnValueChangedAsObservable() emits the latest value at the time of subscription. If initial-value search is part of the specification, that is fine. If you do not want to call an API with an empty string or initial value during screen initialization, add Skip(1) or an explicit filter. The Debounce provider also matters: should it run while the game is paused, or should it follow Time.timeScale? In this example, UnityTimeProvider.Update is specified explicitly.

A simple team rule can start like this:

Use case Recommended operation
Login, purchase, confirm, gacha Drop
Search, preview update Switch
Must process in order Sequential
Safe to run in parallel Parallel

For ordinary game UI, avoid using Parallel casually. It is not common for multiple purchases, transitions, or network operations to be safe when executed at the same time.


Repeated-click protection and simultaneous-input protection are different

Repeated-click protection and simultaneous-input protection are not the same thing.

Repeated-click protection handles pressing the same button many times in a short period. Simultaneous-input protection handles multiple buttons, back keys, shortcuts, gamepad inputs, or other input paths firing at nearly the same time.

If each button has its own SubscribeAwait(Drop), different buttons are not mutually exclusive.

_okButton.OnClickAsObservable()
    .SubscribeAwait(async (_, ct) => await DecideAsync(ct), AwaitOperation.Drop)
    .AddTo(this);

_cancelButton.OnClickAsObservable()
    .SubscribeAwait(async (_, ct) => await CancelAsync(ct), AwaitOperation.Drop)
    .AddTo(this);
Enter fullscreen mode Exit fullscreen mode

In this form, pressing OK and Cancel almost simultaneously can still run both operations. Drop is applied per Observable stream.

If operations should be mutually exclusive, first merge them into one event stream.

private enum DialogAction
{
    Ok,
    Cancel
}
Enter fullscreen mode Exit fullscreen mode
Observable.Merge(
        _okButton.OnClickAsObservable().Select(_ => DialogAction.Ok),
        _cancelButton.OnClickAsObservable().Select(_ => DialogAction.Cancel))
    .SubscribeAwait(
        async (action, ct) =>
        {
            await ExecuteDialogActionAsync(action, ct);
        },
        AwaitOperation.Drop)
    .AddTo(this);
Enter fullscreen mode Exit fullscreen mode

Now OK and Cancel belong to the same mutual-exclusion group. If another event arrives while the operation is running, it will be dropped.

Still, this is only duplicate-execution prevention while the operation is running. If a confirmation dialog must accept exactly one action and never accept another one afterward, use session-level one-shot control.

Observable.Merge(
        _okButton.OnClickAsObservable().Select(_ => DialogAction.Ok),
        _cancelButton.OnClickAsObservable().Select(_ => DialogAction.Cancel))
    .Take(1)
    .SubscribeAwait(
        async (action, ct) =>
        {
            await ExecuteDialogActionAsync(action, ct);
        },
        AwaitOperation.Drop,
        cancelOnCompleted: false)
    .AddTo(this);
Enter fullscreen mode Exit fullscreen mode

Take(1) stops input after the first event. That is separate from whether the async operation already in progress should be canceled or allowed to finish. For purchases, payments, server state changes, and gacha confirmation, you must also design cancellation, partial success, retry behavior, and idempotency.

Another common pattern is to keep an explicit confirmed/closed flag.

private bool _closed;

private async ValueTask ExecuteDialogActionAsync(DialogAction action, CancellationToken ct)
{
    if (_closed)
    {
        return;
    }

    _closed = true;
    SetButtonsInteractable(false);

    await ExecuteAsync(action, ct);
}
Enter fullscreen mode Exit fullscreen mode

It is also safer to put a shared gate on the processing side, not only in the UI stream. Input paths tend to grow over time: shortcuts, gamepads, keyboard back, test hooks, or direct method calls.

private bool _isProcessing;

private async ValueTask ExecuteDialogActionAsync(DialogAction action, CancellationToken ct)
{
    if (_isProcessing)
    {
        return;
    }

    _isProcessing = true; // Set this at the very beginning of the entry point.

    try
    {
        SetButtonsInteractable(false);

        switch (action)
        {
            case DialogAction.Ok:
                await DecideAsync(ct);
                break;
            case DialogAction.Cancel:
                await CancelAsync(ct);
                break;
        }
    }
    finally
    {
        _isProcessing = false;

        if (this != null && gameObject.activeInHierarchy)
        {
            SetButtonsInteractable(true);
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

This bool gate is a simple example for UI / Presenter code running on the Unity main thread. If the operation can be called from a service layer, background thread, SDK callback, or multiple threads, use an appropriate synchronization method such as SemaphoreSlim, Interlocked, lock, or a dedicated job queue.

Also be careful with finally. Re-enabling buttons unconditionally can be wrong if the view was destroyed, the screen was closed, or the button was disabled for another reason. In production code, choose a policy such as restoring the previous interactable state, binding a processing state from the ViewModel, or not re-enabling UI when the screen closes.

A useful way to separate responsibilities is this:

Layer Responsibility
View Merge multiple input paths into one entry point
Observable stream Drop later events while processing
Presenter / ViewModel Hold shared gates and confirmed states
Service / Server Provide idempotency and duplicate-execution protection for important operations

A good team rule is:

UI operations that must not run together should not be handled by independent per-button subscriptions. Merge them as one mutual-exclusion group and pass them through one SubscribeAwait(Drop) or one shared gate.


Do not overuse EveryUpdate

R3 provides Observable.EveryUpdate(). It is convenient because it creates a stream that emits every frame. But if everything becomes EveryUpdate, the code can become harder to follow than a normal Update method.

A bad example:

Observable.EveryUpdate()
    .Subscribe(_ =>
    {
        Move();
        Rotate();
        CheckGround();
        UpdateAnimation();
    })
    .AddTo(this);
Enter fullscreen mode Exit fullscreen mode

There is little benefit in writing this with R3. A normal Update is clearer.

void Update()
{
    Move();
    Rotate();
    CheckGround();
    UpdateAnimation();
}
Enter fullscreen mode Exit fullscreen mode

EveryUpdate is useful when you want to treat frame updates as an event stream and compose it.

Observable.EveryUpdate()
    .Where(_ => _player.IsGrounded)
    .Take(1)
    .Subscribe(_ => OnLanded())
    .AddTo(this);
Enter fullscreen mode Exit fullscreen mode

Or when you want to observe something for a fixed number of frames:

Observable.EveryUpdate()
    .Take(60)
    .Subscribe(_ => UpdatePreview())
    .AddTo(this);
Enter fullscreen mode Exit fullscreen mode

However, if UpdatePreview() performs heavy work such as rebuilding UI or generating meshes, running it for 60 consecutive frames may still be too expensive. This is not an R3-specific issue; be careful with the actual work performed every frame.

A practical rule:

  • Use normal Update for ordinary game-loop logic.
  • Use EveryUpdate only when frame events need stream composition.
  • Long-lived EveryUpdate subscriptions should have a clear reason.
  • EveryUpdate().Subscribe(...) with no meaningful composition should be questioned in review.

If you need FixedUpdate-like timing, R3 can use UnityFrameProvider.FixedUpdate.

Observable.EveryUpdate(UnityFrameProvider.FixedUpdate)
    .Subscribe(_ => CheckFixedFrameEvent())
    .AddTo(this);
Enter fullscreen mode Exit fullscreen mode

Even so, physics logic itself is often clearer in a normal FixedUpdate(). Use R3 here only when you need to compose events that happen on the FixedUpdate timing.


Be explicit about TimeProvider and FrameProvider

R3 can specify providers for time-based and frame-based operations. In Unity, the defaults are the Update-based providers: UnityTimeProvider.Update and UnityFrameProvider.Update.

Use Timer or Interval when waiting by time.

Observable.Timer(TimeSpan.FromSeconds(1), UnityTimeProvider.Update)
    .Subscribe(_ => ShowMessage())
    .AddTo(this);
Enter fullscreen mode Exit fullscreen mode

If the operation should ignore Time.timeScale, use UpdateIgnoreTimeScale explicitly.

Observable.Timer(TimeSpan.FromSeconds(1), UnityTimeProvider.UpdateIgnoreTimeScale)
    .Subscribe(_ => ShowMessage())
    .AddTo(this);
Enter fullscreen mode Exit fullscreen mode

If you want to wait for a number of frames, use frame-based APIs instead of seconds.

Observable.TimerFrame(60, UnityFrameProvider.Update)
    .Subscribe(_ => ShowAfter60Frames())
    .AddTo(this);
Enter fullscreen mode Exit fullscreen mode

Omitting the provider is not always wrong. But in games, whether a timer should stop during pause, ignore timeScale, or be frame-based is part of the specification. For important code, specifying the provider makes review easier.


Use Observable Tracker for review and investigation

R3 has Observable Tracker, a debugging aid for inspecting Observable and subscription state.

When a team first introduces R3, common problems include:

  • Subscriptions remain after closing a screen.
  • Opening the same view increases the subscription count.
  • EveryUpdate keeps running longer than intended.
  • Nobody knows which Observable is still alive.

Observable Tracker is useful for investigating these problems.

That said, it should not replace code review. The basic rule is to decide lifetimes in code first, then use Observable Tracker for suspicious areas or during the initial adoption phase.


Do not let R3 leak too deeply into domain logic

Once R3 is introduced, it can be tempting to turn everything into Observable streams. But if the core domain logic also becomes reactive, people who are not comfortable with R3 may struggle to read or modify it.

For example, damage calculation is often just ordinary C#.

public void Damage(int damage)
{
    if (damage <= 0)
    {
        return;
    }

    Hp = Mathf.Max(0, Hp - damage);
}
Enter fullscreen mode Exit fullscreen mode

There is no need to express this calculation itself as an Observable chain.

Use R3 to notify the result.

private readonly Subject<Unit> _dead = new();
public Observable<Unit> Dead => _dead;

public void Damage(int damage)
{
    if (damage <= 0)
    {
        return;
    }

    Hp = Mathf.Max(0, Hp - damage);

    if (Hp == 0)
    {
        _dead.OnNext(Unit.Default);
    }
}
Enter fullscreen mode Exit fullscreen mode

In real code, the class that owns the Subject should also dispose it.

The basic policy is:

  • Keep calculations, decisions, and state transitions as ordinary C#.
  • Use R3 for state-change notifications, UI updates, and event wiring.
  • Do not bury important specifications deep inside Observable chains.

This matters a lot in team development. If only the R3 expert can safely change the code, maintainability has already gone down.


Split complex chains

Short R3 chains can be readable. Long ones become hard to review quickly.

_model.Items
    .Where(items => items != null)
    .Select(items => items.Where(x => x.IsVisible))
    .Select(items => items.OrderBy(x => x.SortOrder))
    .Select(items => items.Take(10))
    .Subscribe(items => UpdateList(items))
    .AddTo(this);
Enter fullscreen mode Exit fullscreen mode

This is still understandable, but real projects often add more conditions.

When a chain grows, split it into named intermediate Observables or ordinary methods.

var visibleItems = _model.Items
    .Where(items => items != null)
    .Select(FilterVisibleItems);

var sortedItems = visibleItems
    .Select(SortItems);

sortedItems
    .Subscribe(UpdateList)
    .AddTo(this);
Enter fullscreen mode Exit fullscreen mode
private IEnumerable<Item> FilterVisibleItems(IEnumerable<Item> items)
{
    return items.Where(x => x.IsVisible);
}

private IEnumerable<Item> SortItems(IEnumerable<Item> items)
{
    return items.OrderBy(x => x.SortOrder).Take(10);
}
Enter fullscreen mode Exit fullscreen mode

Named methods are easier to review than business rules hidden inside a long chain.

Use ToReadOnlyReactiveProperty() when the result should be stored as state. Remember that the generated ReadOnlyReactiveProperty also owns upstream subscriptions, so it needs a lifetime too.


Do not overuse ToObservable

ToObservable() is useful, but not everything should become an Observable.

If you only need to calculate a total, LINQ is enough.

var total = items.Sum(x => x.Price);
Enter fullscreen mode Exit fullscreen mode

There is no need to write this as an Observable chain.

items.ToObservable()
    .Select(x => x.Price)
    .Sum()
    .Subscribe(total => Debug.Log(total));
Enter fullscreen mode Exit fullscreen mode

R3 is strong when values flow over time. If you are just aggregating an in-memory collection right now, LINQ or a normal foreach is usually clearer.


Do not rely on AddTo(this) for every lifetime

AddTo(this) is the basic way to tie a subscription to the lifetime of a MonoBehaviour or GameObject.

_model.Hp
    .Subscribe(UpdateHp)
    .AddTo(this);
Enter fullscreen mode Exit fullscreen mode

But AddTo(this) usually means lifetime until Destroy. It does not automatically mean disabled, hidden, tab-switched, popup-closed, or re-bound.

If a tab creates subscriptions every time it is selected, use a separate DisposableBag for that tab binding.

private DisposableBag _tabDisposables;

public void BindTab(TabModel tab)
{
    _tabDisposables.Dispose();
    _tabDisposables = default;

    tab.Items
        .Subscribe(UpdateItems)
        .AddTo(ref _tabDisposables);
}

private void OnDestroy()
{
    _tabDisposables.Dispose();
}
Enter fullscreen mode Exit fullscreen mode

If a disposed DisposableBag receives a new disposable, the new one is disposed immediately. Reset it with = default before reusing the field.

The same applies to Views whose Initialize() method may be called multiple times.

private DisposableBag _bindDisposables;

public void Initialize(PlayerStatus status)
{
    _bindDisposables.Dispose();
    _bindDisposables = default;

    status.Hp
        .Subscribe(UpdateHp)
        .AddTo(ref _bindDisposables);
}
Enter fullscreen mode Exit fullscreen mode

If a prefab is reused, dependency injection runs again, or a tab is rebound, AddTo(this) alone can leave the previous subscription alive. Distinguish between GameObject lifetime, display lifetime, and binding lifetime.


How to think about OnErrorResume

R3 does not use the traditional Rx model where OnError stops the subscription. Instead, errors are handled through OnErrorResume.

For UI events, this is often convenient: a failed click should not necessarily kill the button subscription forever. But it also does not mean exceptions can be swallowed silently.

_loginButton.OnClickAsObservable()
    .SubscribeAwait(
        async (_, ct) =>
        {
            await LoginAsync(ct);
        },
        ex =>
        {
            if (ex is OperationCanceledException)
            {
                return;
            }

            Debug.LogException(ex);

            if (this != null && gameObject.activeInHierarchy)
            {
                _dialog.Show("Login failed.");
            }
        },
        _ => { },
        AwaitOperation.Drop)
    .AddTo(this);
Enter fullscreen mode Exit fullscreen mode

The second argument is R3's OnErrorResume handler, not the old Rx OnError. The subscription does not stop.

In production code, decide these rules:

  • Send logs to Debug.LogException or the project's standard logger.
  • Separate user-facing failure messages from developer logs.
  • Decide whether OperationCanceledException should be shown to users.
  • Do not touch a destroyed View after screen transition.
  • For important operations, consider representing success/failure as a return value instead of relying only on Observable error handling.

Patterns to avoid

These are the patterns I would explicitly ban in team development.

Pattern to avoid Problem Alternative
public ReactiveProperty<T> Anyone can write to it Keep it private and expose ReadOnlyReactiveProperty<T>
public Subject<T> Anyone can call OnNext Keep it private and expose Observable<T>
static/global Subject Publisher, subscriber, and lifetime become unclear Keep events within a feature or screen scope
Subscribe without lifetime Leaks and duplicate execution Use AddTo / DisposableBag
Adding subscriptions every Initialize() Re-initialization creates duplicates Dispose binding disposables before rebinding
Per-button Drop for exclusive actions Does not prevent simultaneous input Merge by mutual-exclusion group
Using EveryUpdate as Update Intent becomes harder to follow Use normal Update for ordinary loops
Making everything ToObservable() Harder than LINQ or foreach Use Observable only where time flow matters
Very long chains Hard to review Split into intermediate Observables or methods
Swallowing errors in OnErrorResume Failures disappear from monitoring Define logging and display rules

Even when exposing ReadOnlyReactiveProperty<T> or Observable<T>, a caller who knows the concrete object could force an unsafe cast. The point is not to make abuse impossible through types alone. The point is to hide write access from the public API and make unsafe casts a team-rule violation.


Team operation rules

Based on the table above, code review can focus on four questions:

  1. Is the write entry point hidden?
  2. Is the subscription lifetime clear, including re-initialization?
  3. Are repeated clicks, simultaneous input, and one-shot control treated as separate problems?
  4. Is the code using Observable where ordinary C#, LINQ, Update, or async/await would be clearer?

Do not start by creating too many detailed rules. It is safer to first establish private write access, lifetime management, input exclusivity, and limited EveryUpdate usage.


Adoption scope

R3 is safer to introduce gradually, not all at once across the entire project.

A practical adoption order is:

  1. UI events and repeated-click protection
  2. ViewModel-to-View state notification
  3. Loading progress and busy flags
  4. Composition of multiple inputs
  5. Game events only where needed

If you try to convert every Model, Service, and event to R3 at once, review cost will rise quickly. In a team with R3 beginners, starting from UI and ViewModel code usually leads to smoother adoption.


Notes for migrating from UniRx

If your team has UniRx experience, people may treat R3 as just a newer UniRx. But several design details are different.

Important points include:

  • Use Debounce, not Throttle.
  • Do not assume OnError stops the subscription.
  • Use SubscribeAwait and AwaitOperation for async UI events.
  • Follow R3's disposable patterns such as DisposableBag.
  • Be explicit about Unity providers where timing matters.

Migration is not only API replacement. Revisit error handling, async behavior, and subscription lifetime design.


Recommended practical structure

A typical maintainable structure is to separate Model, Presenter/ViewModel, and View responsibilities.

Model

The Model owns state and publishes change notifications. It does not expose write access.

public sealed class PlayerStatus : IDisposable
{
    private readonly ReactiveProperty<int> _hp = new(100);
    public ReadOnlyReactiveProperty<int> Hp => _hp;

    public void Damage(int damage)
    {
        if (damage <= 0)
        {
            return;
        }

        _hp.Value = Mathf.Max(0, _hp.Value - damage);
    }

    public void Dispose()
    {
        _hp.Dispose();
    }
}
Enter fullscreen mode Exit fullscreen mode

ViewModel / Presenter

The ViewModel or Presenter converts Model state into UI-friendly state. Any generated ReadOnlyReactiveProperty must also have a lifetime.

private DisposableBag _disposables;
private ReadOnlyReactiveProperty<bool> _canDecide;

public ReadOnlyReactiveProperty<bool> CanDecide => _canDecide;

public void Initialize(PlayerStatus status, Wallet wallet)
{
    _disposables.Dispose();
    _disposables = default;

    _canDecide = Observable.CombineLatest(
            status.Hp,
            wallet.Gold,
            (hp, gold) => hp > 0 && gold >= 100)
        .ToReadOnlyReactiveProperty();

    _canDecide.AddTo(ref _disposables);
}

public void Dispose()
{
    _disposables.Dispose();
}
Enter fullscreen mode Exit fullscreen mode

If Initialize() may be called more than once, rebuild subscriptions after disposing the previous ones. If the class is single-initialization only, make that assumption explicit in the design.

View

The View sends UI events to the Presenter and reflects Presenter state into UI.

private DisposableBag _bindDisposables;

public void Initialize(PlayerPresenter presenter)
{
    _bindDisposables.Dispose();
    _bindDisposables = default;

    presenter.CanDecide
        .Subscribe(canDecide => _decideButton.interactable = canDecide)
        .AddTo(ref _bindDisposables);

    _decideButton.OnClickAsObservable()
        .SubscribeAwait(
            async (_, ct) => await presenter.DecideAsync(ct),
            AwaitOperation.Drop)
        .AddTo(ref _bindDisposables);
}

private void OnDestroy()
{
    _bindDisposables.Dispose();
}
Enter fullscreen mode Exit fullscreen mode

If Initialize() is guaranteed to be called only once, AddTo(this) can be enough. If the View can be rebound or re-initialized, explicitly dispose the previous binding subscriptions.


Choosing not to use R3 is also important

R3 is useful, but not every piece of code needs it.

Examples where R3 is often unnecessary:

  • Simple sequential logic
  • One-time loading
  • Complex damage calculation
  • AI decision-making core
  • Core physics update logic
  • One-off collection aggregation

These are often clearer with ordinary C#, async/await, Update, or LINQ.

Use R3 where you need to compose events or value flows:

  • UI events
  • State-change notifications
  • Composition of multiple inputs
  • Repeated-click and simultaneous-input protection
  • Loading progress
  • ViewModel-to-View notification

The purpose of R3 is not to make code look advanced. It is to make event flow and lifetime explicit enough for the team to maintain.


Conclusion

R3 is very effective when its usage is constrained. It works well for UI events, ViewModel-to-View notifications, loading progress, busy flags, and input composition. In those areas, it can express intent more clearly than hand-written event management.

But if the team uses it freely everywhere, Subject, EveryUpdate, long chains, and subscriptions with unclear lifetimes will multiply.

The most important point in this article is to separate repeated clicks, simultaneous input, one-shot control, subscription lifetime, and time providers. For a 10-person Unity team that needs to maintain a project over time, R3 should not be introduced as a tool for making everything Observable. It should be introduced as a restricted set of rules for writing notification and wiring code safely.

Top comments (0)