Cysharp / R3

The new future of dotnet/reactive and UniRx.
MIT License
2.2k stars 96 forks source link

Add `ReactiveCommand<TInput, TOutput>` #249

Closed erri120 closed 2 months ago

erri120 commented 2 months ago

R3 has ReactiveCommand<TInput> which inherits from Observable<TInput>. The observable contains the inputs, but there's no variant that also has outputs:

ReactiveCommand<TInput, TOutput> : Observable<TOutput>

Instead of passing Action<TInput>, this variant would use Func<TInput, TOutput>. My team has been using the ReactiveUI counterpart with outputs everywhere because it makes certain UI code much simpler. Since reactive commands are observables, we merge these observables to be able to react to a list of commands in situations where we work with lists or trees of items.

neuecc commented 2 months ago

I checked the ReactiveUI code, but it looks needlessly complicated and the performance seems to be very poor...

By the way, is it not possible to use .Select(input => output) for the code that binds ReactiveCommand<TInput> and connects it?

erri120 commented 2 months ago

I checked the ReactiveUI code, but it looks needlessly complicated and the performance seems to be very poor...

That's why we're looking at R3 for an alternative.

By the way, is it not possible to use .Select(input => output) for the code that binds ReactiveCommand<TInput> and connects it?

It very much depends on the use-case:

file class Foo
{
    public Foo(Observable<Bar> observable)
    {
        // 1) R3 command
        observable
            .Select(static bar => bar.R3Command.AsObservable())
            .Merge()
            .Subscribe(static unit => { /* no name */ });

        // 2) R3 command with select
        observable
            .Select(static bar => bar.R3Command.Select(bar, static (_, bar) => bar.Name))
            .Merge()
            .Subscribe(static name => { /* */ });

        // 3) ReactiveUI
        observable
            .Select(static bar => bar.ReactiveUICommand.ToObservable())
            .Merge()
            .Subscribe(static name => { /* */ });
    }
}

file class Bar
{
    public string Name { get; }

    public R3.ReactiveCommand<R3.Unit> R3Command { get; }

    public ReactiveUI.ReactiveCommand<System.Reactive.Unit, string> ReactiveUICommand { get; }

    public Bar(string name)
    {
        Name = name;

        R3Command = new R3.ReactiveCommand<R3.Unit>();
        ReactiveUICommand = ReactiveUI.ReactiveCommand.Create<System.Reactive.Unit, string>(_ => Name);
    }
}

This is a common pattern in the application we're working on, where multiple instances of Bar exists in some list or tree, and the parent Foo observes the commands. The commands are usually bound to some button in the UI. While Command.Select can work if the command doesn't actually do anything, besides being a specialized Subject<Unit> that triggers OnNext when a button in the UI is clicked, if the command needs to do some amount of work, we need an output.

Take a file picker for example:

file class Foo
{
    public R3.ReactiveCommand<R3.Unit> PickFileCommand { get; }

    public Foo()
    {
        PickFileCommand = new R3.ReactiveCommand<R3.Unit>(async (_, cancellationToken) =>
        {
            var path = await PickFileAsync(cancellationToken);
        });
    }

    private async ValueTask<AbsolutePath> PickFileAsync(CancellationToken cancellationToken) => throw new NotImplementedException();
}

The command gets bound to a button on the UI, but here we want to do something with the output. Of course, we can just add a Subject<AbsolutePath>:

public R3.ReactiveCommand<R3.Unit> PickFileCommand { get; }
private R3.Subject<AbsolutePath> PickedFileSubject { get; } = new();

public Foo()
{
    PickedFileSubject
        .Where(path => path.FileExists)
        .Subscribe(path => { /* do stuff */ });

    PickFileCommand = new R3.ReactiveCommand<R3.Unit>(async (_, cancellationToken) =>
    {
        var path = await PickFileAsync(cancellationToken);
        PickedFileSubject.OnNext(path);
    });
}

This gets us where we want, but it's not nice to use. I hope this illustrates why you might want an output on the command. It's not the end of the world for us, but it would make our lives much easier.

neuecc commented 2 months ago

Thanks for the detailed explanation, I understand now. ReacitveCommand<TInput, TOutput> was added in v1.2.8, please try it.

erri120 commented 2 months ago

Thanks, will try it out.

erri120 commented 2 months ago

Works great!