mrpmorris / Fluxor

Fluxor is a zero boilerplate Flux/Redux library for Microsoft .NET and Blazor.
MIT License
1.24k stars 141 forks source link

How to make sure that state is reduced by the time SubscribeToAction is called? #299

Closed uhfath closed 2 years ago

uhfath commented 2 years ago

Suppose there is a state SomeDataState and an action QuerySomeDataAction which has an effect that queries data from backend. This effect then dispatches SomeDataReadyAction action that reduces the state SomeDataState with the results.

Also in code there is a SubscribeToAction<SomeDataReadyAction> in which I check the data from the effect and also the state. But by the time this event is fired the SomeDataState is not reduced from SomeDataReadyAction.

Am I right in assumption that in order for this to work reliably (event and state be in sync) one needs to dispatch another action, e.g. SomeDataStateReadyAction from within SomeDataReadyAction effect, copy everything from SomeDataReadyAction and subscribe to that? Will the state be already reduced with SomeDataReadyAction by that time?

Another way I see is to add some sort of a StateChangedSource enum to SomeDataState, set it during every state reduce and subscribe to state changes instead of actions. But not sure if this method is better since it requires a lot of source types in my case.

mrpmorris commented 2 years ago

The subscribe is really for grabbing hold of objects that don't get reduced into state. What are you trying to achieve?

uhfath commented 2 years ago

There are some parts of code which depend on the state and on some data from backend. The issue is that querying data from backend also changes the state and I need to compare these at some point. I guess the main question is will the state guaranteed to be reduced from the specific action by the time the effect is called with the same action?

mrpmorris commented 2 years ago

No, the callbacks are made before the state changes. I don't know if people currently depend on that to do the opposite of what you are doing.

Can you explain your scenario more? What is the exact business requirement?

uhfath commented 2 years ago

There is a page with a datagrid and some filtering capabilities. This is the state which is used:

[FeatureState]
public record ClientListState
{
    public bool IsLoading { get; init; }
    public string SearchText { get; init; }
    public int PageIndex { get; init; }
    public int PageSize { get; init; }
    public string SortLabel { get; init; }
    public ColumnSortDirection.Direction SortDirection { get; init; }
    public RemoteData<Logic.Features.Clients.Models.ClientShort> Clients { get; init; } = new();
}

Here is an action used to query the data:

public class ClientListQuery
{
    public string SearchText { get; }
    public int PageIndex { get; }
    public int PageSize { get; }
    public string SortLabel { get; }
    public ColumnSortDirection.Direction SortDirection { get; }

    public ClientListQuery(
        string searchText,
        int pageIndex,
        int pageSize,
        string sortLabel,
        ColumnSortDirection.Direction sortDirection,
        CancellationToken cancellationToken = default)
        : base(cancellationToken)
    {
        if (string.IsNullOrWhiteSpace(sortLabel))
        {
            throw new ArgumentException($"'{nameof(sortLabel)}' cannot be null or whitespace.", nameof(sortLabel));
        }

        SearchText = searchText;
        PageIndex = pageIndex;
        PageSize = pageSize;
        SortLabel = sortLabel;
        SortDirection = sortDirection;
    }
}

Here is the reducer for this action:

private static class Reducers
{
    [ReducerMethod]
    public static States.ClientListState Reduce(States.ClientListState clientListState, ClientListQuery clientListQuery) =>
        clientListState with
        {
            IsLoading = true,
        };
}

And the effect for it:

private class Effect : Effect<ClientListQuery>
{
    private readonly LogicService _logicService;

    public Effect(
        LogicService logicService)
    {
        this._logicService = logicService;
    }

    public override async Task HandleAsync(ClientListQuery action, IDispatcher dispatcher)
    {
        try
        {
            var clients = await _logicService.QueryAsync(new Logic.Features.Clients.Queries.GetClientsQuery(
                action.SearchText,
                action.PageIndex - 1,
                action.PageSize,
                action.SortLabel,
                action.SortDirection), action.CancellationToken);

            dispatcher.DispatchAction(new Events.ClientListQuerySuccessEvent(
                action.SearchText,
                clients.PageIndex.Value + 1,
                action.PageSize,
                action.SortLabel,
                action.SortDirection,
                clients));
        }
        catch (OperationCanceledException ex)
        {
            dispatcher.DispatchAction(new Events.ClientListQueryCancelledEvent(ex));
        }
        catch (Exception ex)
        {
            dispatcher.DispatchAction(new Events.ClientListQueryFailedEvent(ex));
            throw;
        }
    }
}

Here is an action which is dispatched when data is successfully retrieved from backend:

public class ClientListQuerySuccessEvent
{
    public string SearchText { get; }
    public int PageIndex { get; }
    public int PageSize { get; }
    public string SortLabel { get; }
    public ColumnSortDirection.Direction SortDirection { get; }
    public RemoteData<Logic.Features.Clients.Models.ClientShort> Clients { get; }

    public ClientListQuerySuccessEvent(
        string searchText,
        int pageIndex,
        int pageSize,
        string sortLabel,
        ColumnSortDirection.Direction sortDirection,
        RemoteData<Logic.Features.Clients.Models.ClientShort> clients)
    {
        if (string.IsNullOrWhiteSpace(sortLabel))
        {
            throw new ArgumentException($"'{nameof(sortLabel)}' cannot be null or whitespace.", nameof(sortLabel));
        }

        SearchText = searchText;
        PageIndex = pageIndex;
        PageSize = pageSize;
        SortLabel = sortLabel;
        SortDirection = sortDirection;

        Clients = clients ?? throw new ArgumentNullException(nameof(clients));
    }
}

And it's reducer:

private static class Reducers
{
    [ReducerMethod]
    public static States.ClientListState Reduce(States.ClientListState clientListState, ClientListQuerySuccessEvent clientListQuerySuccessEvent) =>
        clientListQuerySuccessEvent.Reduce(clientListState) with
        {
            IsLoading = false,
            SearchText = clientListQuerySuccessEvent.SearchText,
            PageIndex = clientListQuerySuccessEvent.PageIndex,
            PageSize = clientListQuerySuccessEvent.PageSize,
            SortLabel = clientListQuerySuccessEvent.SortLabel,
            SortDirection = clientListQuerySuccessEvent.SortDirection,
            Clients = clientListQuerySuccessEvent.Clients,
        };
}

The idea is this:

  1. when a user searches something or switches pages or sorts the table the ClientListQuery action is dispatched. Reducer sets the table to IsLoading state to show some spinners. The effect queries for data. When data is ready the backed sends a corrected page index along with it (and some stats, like total row count). This corrected page index is used in cases when a user browsers a last page (e.g. №50) and some other user deletes a last record from it. Now when querying there is no page №50 and we need to return the last available page, which is №49. This last correct page index is returned along with data to the client. The client then corrects it's pager to show correct page numbers.
  2. after ClientListQuery's effect finishes it dispatches ClientListQuerySuccessEvent which reduces the state with correct data.
  3. the state is directly connected with datagrid (the datagrid component shows the spinner or the data when needed).
  4. when data is ready to be shown in datagrid I need to save the state into browsers address using NavigationManager.NavigateTo through building the correct URL with query parameters. This is used when a user navigates to some page and then back to datagrid.
  5. setting URL generates OnParametersSet event for the page which is also used to store the state and query data
  6. to circumvent the infinite loop from saving the sate to URL and re-querying data which again leads to saving etc. I need a way to compare the current state with the one returned from callback and only if they differ refresh the data.

In order for this to work I need some callback for when data is ready which is guaranteed to fire only after state is reduced with the same data. Currently my assumption was that when effect is called the state is already reduced for the same action. But if effects and action events are not in any way in sync with reducers then this method at some point will fail.

mrpmorris commented 2 years ago

Fixed in 5.4

uhfath commented 2 years ago

Much appreciated!