How do we await the result of an Action/Effect in an ItemProviderDelegate?

2.4k Views Asked by At

In my razor component, I am using a Virtualize component (docs here) with an ItemsProviderDelegate which is implemented as an async method to load Data Transfer Objects (DTOs) in batches from an API. The method looks something like this:

private async ValueTask<ItemsProviderResult<Dto>> LoadDtosAsync(ItemsProviderRequest request)
{
    // Massage parameters and dispatch action
    // _stateFacade is essentially a wrapper around 
    // Dispatcher.Dispatch(new LoadDtosAction())

    _stateFacade.LoadDtos(request.StartIndex, request.Count);

    // I make the assumption here that 'IsLoading' is immediately 
    // 'true' after the LoadDtosAction is dispatched. I think
    // that this is probably a bad assumption because
    // Dispatcher.Dispatch() is probably not synchronous
    // under-the-hood.

    // dtoState is an IState<Dto>
    while(_dtoState.Value.IsLoading)
    {
        // My current 'solution' to wait for the data to be loaded.
        await Task.Delay(100);
    }

    // Provide the items to the Virtualize component for rendering...
    return new ItemsProviderResult<Dto>(
        _dtoState.Value.VirtualizedDtos ?? new List<Dto>(),
        _dtoState.Value.DtosServerCount ?? 0
    );

}

This has proven to be an effective approach to rendering batches of data from collections of models in the backend which might be very large while keeping request sizes small. The client application only needs to request a handful of objects from the API at a time, while the UI does not require silly "page" controls as the user can intuitively scroll through components which display the data.

Fluxor is used to manage the state of the client application, including the current DTOs which have been requested by the Virtualize component. This abstracts away the logic to request batches of DTOs from the API and allows side effects to be triggered depending on which component dispatches the action.

Many of the Action types in the app have an object? Sender property which contains a reference to the component which dispatched the action. This approach works when the original method in the component which dispatched the desired action does not require the resulting state from an action to return. Effects can then invoke callback methods depending on the type of the component sending the action, for example:

public class UpdateDtoEffect : Effect<UpdateDtoSuccessAction>
{
    protected override async Task HandleAsync(UpdateDtoSuccessAction action, IDispatcher dispatcher)
    {
        var updateDtoForm = action.Sender as UpdateDtoForm;
        if (updateDtoForm is not null)
        {
            await updateDtoForm.OnSuccessfulUpdate.InvokeAsync();
        }
    }
}

When OnSuccessfulUpdate is invoked by the above effect, the reducer for this action will have updated the state so the callback method can rely on up-to-date state information.

An ItemsProviderDelegate has posed an interesting exception to this approach. In order to correctly implement the delegate, we need to return the list of items and the count of how many items are available on the server. This information is stored in the state for this feature which is updated by a reducer when the LoadDtosAction is successful. In the current implementation (generically expressed above), the LoadDtosAsync method makes 2 assumptions that I don't like:

  1. That the state value isLoading is immediately set to true upon the LoadDtosAction being dispatched. I don't think this is always true, and so the component is sometimes immediately interrogating the state value to update itself (which will result in displaying the previous state as opposed to the resulting state).

  2. That the resulting action-reducer-effect chain will eventually update the state isLoading value to false.

Is there an approach that will allow the ItemsProviderDelegate implementation to dispatch the LoadDtosAction and "await" the results of the action to return the ItemsProviderResult?

  • Edit - The flow of the action looks something like this:
LoadDtosAction => 
LoadDtosActionReducer (new state, 'isLoading':true) =>
LoadDtosActionEffect (performs asynchronous API call) =>
LoadDtosSuccessAction =>
LoadDtosSuccessActionReducer (new state, 'VirtualizedDtos':{IEnumerable<Dto>}, 'DtosServerCount':{int})
LoadDtosSuccessEffect (perform optional asynchronous callbacks to 'Sender')
2

There are 2 best solutions below

2
On BEST ANSWER

I think you could do something like this

  1. Have a TaskCompletionSource<ItemsProviderResult<Employee>> member in your component.
  2. In the LoadDtosAsync dispatch an action that has a property containing a reference to that TaskCompletionSource
  3. await that TaskCompletionSource

That's the UI side done, now the store part

[ReducerMethod(typeof(LoadDtosAction))]
public static MyState ReduceLoadDtosAction(MyState state) => state with {IsLoading = true };

[ReducerMethod(typeof(LoadDtosActionResult))]
public static MyState ReduceLoadDtosActionResult(MyState state) = state with {IsLoading = false; }

[EffectMethod]
public async Task HandleLoadDtosAsync(LoadDtosAction action, IDispatcher dispatcher)
{
  var yourData = await HttpClient.GetJson(............);
  action.TaskCompletionSource.SetResult(yourData);
  Dispatcher.Dispatch(new LoadDtosActionResult()); // Just to set IsLoading = false;
}

Note that this is okay because although the TaskCompletionSource can be considered mutable state, we aren't storing it in the Store itself - we are just passing it around in the state of an action (which can hold mutable data).

0
On

For future archeologists, the best solution I could come up with was to add Fluxor's IActionSubscriber via DI into my component (with the virtualized list which is managed by a redux state) and subscribe to the success/failure actions dispatched when the LoadDtosActionEffect attempts to talk to the API to retrieve the DTOs. The component declares a simple boolean flag which is immediately set to true in LoadDtosAsync, the actions registered with the action subscriber simply set this flag to false when the success/failure actions are dispatched.

I suspect, since Blazor WASM is single-threaded, this flag should not be concurrently modified. I discovered this when I tried to use System.Threading.EventWaitHandle to block while waiting for the DTOs to load.

Pro-tip: don't block in Blazor WASM, you will only achieve deadlock in your application.

The biggest note here is to add a timeout to this code, if some future modification breaks the chain of actions/effects upon which the action subscriptions rely, the loop will still exit and simply use the incorrect state. This result is more desirable than slowly building up tons of concurrent "threads" (which aren't really threads) in the async/await schedule which will just end up eating cycles and killing performance.

The resulting code to wait for an action (or subsequent effects/actions dispatched) to complete:

// Private class member declaration
private bool _refreshingDtos;

// Called in OnInitialized() or OnInitializedAsync()
private void SubscribeToActions()
{
    _actionSubscriber.SubscribeToAction<LoadDtosSuccessAction>(this, action =>
    {
        _refreshingDtos = false;
    });
    _actionSubscriber.SubscribeToAction<LoadDtosFailureAction>(this, action =>
    {
        _refreshingDtos = false;
    });
}

private async ValueTask<ItemsProviderResult<Dto>> LoadDtosAsync(ItemsProviderRequest request)
{
    _stateFacade.LoadDtos(request.StartIndex, request.Count);
    _refreshingDtos = true;

    var delay = 100;
    var timeout = 0;
    while(_refreshingDtos && timeout < 30000)
    {
        await Task.Delay(delay);
        timeout += delay;
    }

    return new ItemsProviderResult<Dto>(
        _dtoState.Value.VirtualizedDtos ?? new List<Dto>(), _dtoState.Value.DtoServerCount ?? 0
    );
}


// Component class implements IDisposable
// to make sure the component is unsubscribed (avoid leaking reference)
void IDisposable.Dispose()
{
    if (_actionSubscriber is not null)
    {
        _actionSubscriber.UnsubscribeFromAllActions(this);
    }
}