Articoli

Gestione dello stato con Fluxor: side effects e middleware

da

Continuiamo con l’analisi e l’esplorazione di Fluxor per mostrare come è possibile integrare un backend per la lettura e la scrittura dei dati nel rigido data-flow imposto dal pattern Flux.

Come abbiamo visto nello scorso articolo, Fluxor è una libreria per la gestione centralizzata dello stato delle nostre applicazioni. In quell’articolo abbiamo introdotto i concetti di Store, Action e Reducer; lo Store è un oggetto che serve a contenere un pezzo dello stato dell’applicazione; questo viene aggiornato soltanto dai Reducer che sono funzioni pure che prendono in input lo stato attuale ed una azione (la Action, appunto) e restituiscono il nuovo stato.

Le nostre applicazioni Blazor, in particolare quelle WASM ed Hybrid ma non solo, si trovano ad interagire con un backend con cui scambiare messaggi via HTTP per il recupero ed il salvataggio dei dati.

Proviamo a vedere assieme come integrare il dialogo con uno strato di persistenza all’interno del flusso dei dati descritto da Fluxor.

Aggiungiamo un backend alla nostra applicazione

Riprendiamo in mano l’applicazione della volta scorsa e iniziamo ad applicare alcune modifiche.

Per prima cosa ci servirà scrivere un po’ di codice per l’applicazione di backend stessa.

Nella solution andiamo ad aggiungere quindi un progetto ASP .NET Core che ospiterà le nostre API. Per questo esempio ho scelto di utilizzare un approccio al backend non basato sui controller ma sulle minimal API in modo da avere tutta la nostra applicazione all’interno di un unico file. Modifichiamo quindi il file Program.cs come segue:

using FluxorSampleApp.Shared;

var builder = WebApplication.CreateBuilder(args);

builder.Services.AddEndpointsApiExplorer();
builder.Services.AddSwaggerGen();
builder.Services.AddCors(options =>
{
    options.AddDefaultPolicy(cfg => cfg
        .AllowAnyOrigin()
        .AllowAnyHeader()
        .AllowAnyMethod());
});

var app = builder.Build();

// Configure the HTTP request pipeline.
if (app.Environment.IsDevelopment())
{
    app.UseSwagger();
    app.UseSwaggerUI();
}

app.UseCors();

app.UseHttpsRedirection();

var cartItems = new List<Item>();

app.MapGet("/cart-items", async () =>
{
    // Simulate db retrieve logic
    await Task.Delay(2000);

    return cartItems;
});

app.MapPost("/cart-items", async (Item itemToAdd) =>
{
    // Simulate db save logic
    await Task.Delay(2000);

    cartItems.Add(itemToAdd);
});

app.Run();

Questo backend ci offre due metodi:

  • GET /cart-items: è una rotta in GET che ci permette di leggere una lista di Item presenti all’interno di un eventuale carrello;
  • POST /cart-items: è una rotta in POST che ci permette di aggiungere oggetti al nostro carrello;

I dati in questo caso sono salvati dentro una lista di oggetti in memoria. È importante però sottolineare un paio aspetti:

  • è necessario configurare una policy per le CORS (righe 7-13 del file sopra) poiché la nostra applicazione Blazor sarà servita su una porta differente da quella in cui sono disponibili le API (mi raccomando fate i bravi e configurate correttamente le CORS, non fate l’allow di ogni origin, metodo ed header come ho fatto io nell’esempio 🙂 );
  • sia nella rotta di GET che nella rotta di POST è stato aggiunto un breve delay di due secondi, ciò ha puro scopo accademico ed è stato aggiunto solo per simulare l’interazione con un database e rendere gli effetti lato UI più evidenti;

Per poter interagire con queste API è necessario configurare l’indirizzo base per l’HttpClient che verrà usato nell’applicazione Blazor. Andiamo quindi nel file Program.cs del progetto Blazor WASM e aggiungiamo la riga che segue:

builder.Services.AddScoped(sp => new HttpClient { BaseAddress = new Uri("https://localhost:7220") });

Così facendo quando inietteremo l’HttpClient all’interno della nostra applicazione Blazor, questo sarà configurato per inviare richieste all’indirizzo delle nostre API.

Recuperiamo i dati dal backend

Se vi ricordate lo stato della nostra applicazione era composto come segue:

[FeatureState]
public record CartState
{
    public ImmutableArray<Item> ItemsInCart { get; } = ImmutableArray.Create<Item>();

    private CartState() { } // Serve a costruire lo stato iniziale

    public CartState(ImmutableArray<Item> itemsInCart)
    {
        ItemsInCart = itemsInCart;
    }
}

Questo stato veniva aggiornato dalla nostra applicazione a fronte dell’aggiunta di un nuovo Item nel carrello sfruttando il meccanismo basato sulle Action e sui Reducers.

Ciò che vogliamo aggiungere alla applicazione è che quando l’utente arriva sulla pagina del carrello questa recuperi i dati dalle nostre API e mentre questo sta avvenendo mostri all’utente un messaggio di caricamento.

Proviamo a sfruttare lo stesso meccanismo della volta scorsa.

Aggiorniamo il CartState per aggiungere un nuovo campo boolean per indicare che l’applicazione sta caricando i dati:

[FeatureState]
public record CartState
{
    public bool IsLoading { get; }
    public ImmutableArray<Item> ItemsInCart { get; } = ImmutableArray.Create<Item>();

    private CartState() { } // Serve a costruire lo stato iniziale

    public CartState(ImmutableArray<Item> itemsInCart, bool isLoading)
    {
        IsLoading = isLoading;
        ItemsInCart = itemsInCart;
    }
}

Aggiorniamo anche il markup del componente per mostrare il messaggio di caricamento, trasformiamo quindi il vecchio file Items.razor da:

<div class="mt-2 row">
    @if (CartState.Value.ItemsInCart.Any())
    {
        @foreach (var item in CartState.Value.ItemsInCart)
        {
            <div class="col-4 mt-2">
                @item.Name - @item.Price €
            </div>
        }
    }
    else
    {
        <span>
            Non ci sono oggetti nel carrello :(
        </span>
    }
</div>

nel nuovo file Items.razor:

<div class="mt-2 row">
    @if (CartState.Value.IsLoading)
    {
        <span>
            Aggiornamento del carrello in corso..
        </span>
    }
    else
    {
        @if (CartState.Value.ItemsInCart.Any())
        {
            @foreach (var item in CartState.Value.ItemsInCart)
            {
                <div class="col-4 mt-2">
                    @item.Name - @item.Price €
                </div>
            }
        }
        else
        {
            <span>
                Non ci sono oggetti nel carrello :(
            </span>
        }
    }
</div>

Creiamo quindi una nuova classe all’interno della cartella Store per rappresentare la nuova Action di cui verrà effettuato il dispatch all’inizializzazione del nostro componente.

public class FetchItemsInCartAction { }

All’interno del file Items.razor.cs, ovvero nella partial class che sta “dietro” al componente, possiamo aggiungere l’override del metodo OnInitialized in cui effettueremo il dispatch della action creata al passo precedente:

protected override void OnInitialized()
{
    base.OnInitialized();
    Dispatcher.Dispatch(new FetchItemsInCartAction());
}

Importante notare come la chiamata al metodo base OnInitialized debba essere fatta prima del dispatch dell’azione; questo è necessario perché, se ben ricordate, il componente Items estende la classe base FluxorComponent che nel metodo OnInitialized contiene tutta la logica di sottoscrizione agli eventi per i cambiamenti dello stato.

Benissimo, abbiamo modificato lo stato, abbiamo creato la Action di cui viene fatto il dispatch quando ne abbiamo bisogno, ci resta quindi soltanto da scrivere il Reducer che si andrà ad occupare dell’aggiornamento dello stato recuperando i dati dall’API.

E qui sorge il problema: abbiamo detto (più volte) che il Reducer è una funzione pura, questo vuol dire che non può effettuare chiamate HTTP al suo interno perché il risultato non dipenderà più soltanto dagli input; introducendo il client HTTP all’interno del reducer andremo ad inserire un elemento di variabilità all’interno della nostra funzione. Infatti la funzione risponderà con un risultato quando non ci saranno problemi di comunicazione, ma risponderà con una eccezione nel caso in cui il backend non sia disponibile.

Effects: gestione delle “impurità”

Lo stesso problema si presenta anche in altre librerie che utilizzano il pattern Flux per la gestione dei dati. Redux, ad esempio, lo ha “risolto” con librerie esterne quali Redux Thunk (che inserisce questi “side effects” all’interno della generazione delle Action), Redux Saga o Redux Loops; Peter Morris ha invece scelto di utilizzare il concetto di Effects per la gestione di questi scenari.

Un Effect è un metodo che reagisce al dispatch di una determinata Action eseguendo dei calcoli ed effettuando il dispatch di una nuova Action.

Questo ci permette di lasciare inalterata la logica di aggiornamento dello Stato effettuato soltanto dai Reducer, ci consente di lasciare che i Reducer siano funzioni pure e ci permette di gestire l’integrazione con delle API.

Possiamo quindi ora scrivere un Effect che è in ascolto del dispatch dell’azione FetchItemsInCartAction; questa funzione si occuperà di recuperare i dati dall’endpoint HTTP e di effettuare il dispatch di una nuova azione che servirà ad aggiornare lo stato del carrello.

Creiamo nella cartella Store/CartUseCase un nuovo file Effects.cs che conterrà tutti gli Effect per questo caso d’uso. In questo file aggiungiamo un metodo statico HandleFetchItemsInCartAction a cui passiamo un oggetto di tipo FetchItemsInCartAction ed uno di tipo IDispatcher. Il primo serve sia al framework per capire a quale Action dovrà “agganciare” questo side-effect, ma serve anche a noi per ricevere determinati input dal componente che ha effettuato il dispatch dell’azione; il secondo oggetto ci servirà per effettuare il dispatch di una nuova azione dopo aver recuperato i dati dal backend. L’uso del metodo statico non è l’unico modo che abbiamo per definire un Effect, gli altri metodi sono dettagliati nella documentazione di Fluxor.

public class Effects
{
    private readonly HttpClient _httpClient;

    public Effects(HttpClient httpClient)
    {
        _httpClient = httpClient;
    }

    [EffectMethod]
    public async Task HandleFetchItemsInCartAction(FetchItemsInCartAction action, IDispatcher dispatcher)
    {
        var itemsInCart = await _httpClient.GetFromJsonAsync<Item[]>("/cart-items");
        dispatcher.Dispatch(new FetchItemsInCartResultAction(itemsInCart));
    }
}

È importante sapere che questa classe Effects sarà istanziata una sola volta per Store e ne condividerà quindi lo stesso lifetime; per le applicazioni Blazor ciò significa istanziare questa classe una volta sola per sessione di navigazione dell’utente. Se avete la necessità di ottenere nuove istanze dei servizi che andate ad iniettare considerate l’uso della IServiceScopeFactory.

Diagramma di flusso dei dati dopo l’inserimento degli Effect

L’azione di cui facciamo il dispatch all’inizializzazione del componente, ovvero FetchItemsInCartAction, andrà comunque gestita da un reducer in maniera da poter gestire il messaggio di caricamento dei dati. Modifichiamo quindi il file Reducers.cs aggiungendo il seguente metodo:

[ReducerMethod]
public static CartState ReduceFetchItemsInCartAction(CartState cartState, FetchItemsInCartAction action)
    => new CartState(itemsInCart: cartState.ItemsInCart, isLoading: true);

Questo metodo genererà un nuovo stato avente gli stessi elementi di quello precedente, ma imposterà a true il campo IsLoading che farà in modo da far aggiornare la UI per mostrare il messaggio di caricamento dati.

Dopo l’Effect, scriviamo anche un Reducer che gestirà gestire l’azione dispatchata dall’Effect stesso; il Reducer si occuperà di sostituire gli elementi presenti nel carrello con quelli giunti dalle API e imposterà a false il campo IsLoading indicando il completamento dell’operazione di recupero dati dal backend.

[ReducerMethod]
public static CartState ReduceFetchItemsInCartResultAction(CartState cartState, FetchItemsInCartResultAction action)
    => new CartState(
        itemsInCart: action.Items.ToImmutableArray()
        , isLoading: false);

L’unica cosa che ci manca è quindi quella di scrivere la classe che rappresenta la Action di “recupero dati effettuato” che conterrà quindi la lista degli oggetti recuperati dalle API. Nello store aggiungiamo quindi il file FetchItemsInCartResultAction.cs così fatto:

public class FetchItemsInCartResultAction
{
    public IEnumerable<Item> Items { get; }

    public FetchItemsInCartResultAction(IEnumerable<Item> items)
    {
        Items = items;
    }
}

La nostra applicazione sarà ora in grado di gestire completamente il recupero dei dati da una fonte esterna!

Allo stesso modo possiamo scrivere Reducer, Effect ed Action per la gestione dell’aggiunta di un nuovo Item nel carrello; per brevità vi risparmio il codice, ma potete trovarlo come sempre nel repository GitHub dell’articolo.

Demo applicazione
Demo applicazione

Redux Dev Tools e l’uso dei Middleware

Come già abbiamo visto la volta scorsa il framework è piuttosto verboso, i pezzi che si muovono sono molti e c’è il rischio di perdersi nel momento in cui proviamo a capire come si è giunti allo stato attuale.

Per fortuna Fluxor ci mette a disposizione anche la possibilità di utilizzare i Redux DevTools; questo tool è disponibile come estensione per i browser più utilizzati (Chrome, Edge e Firefox), come applicazione standalone o come componente React e ci permette di visualizzare la cronologia dello stato della nostra applicazione, navigare tutti i cambi di stato a fronte delle varie Action ed effettuare il replay delle action stesse passo dopo passo.

Per integrarlo nella nostra applicazione è sufficiente aggiungere il pacchetto Nuget Fluxor.Blazor.Web.ReduxDevTools e modificare il file Program.cs del progetto Blazor come segue:

builder.Services.AddFluxor(options =>
{
    options.ScanAssemblies(typeof(Program).Assembly);
    options.UseReduxDevTools();
});

Abilitare i Redux DevTools in ogni ambiente è, oltre che oneroso in termini di performance, molto pericoloso perché consente all’utente di accedere in maniera puntuale e molto semplice a tutti gli store dell’applicazione; si consiglia infatti di aggiungere il pacchetto solo in ambiente di sviluppo sfruttando la compilazione condizionale di C#.

Dopo aver installato l’estensione per il browser di riferimento, possiamo avviare l’applicazione, aprire i Developer Tools con F12 ed andare nel tab Redux per visualizzare tutte le funzionalità che questo strumento ci mette a disposizione.

L’integrazione con i Redux DevTools è possibile grazie ad un altro meccanismo di base di Fluxor ovvero i Middleware. I middleware ci consentono di inserire qualsivoglia logica in un punto qualsiasi del flusso dei dati di Fluxor. Ci permettono di eseguire azioni all’inizializzazione di uno store, inibire il dispatch di una determinata action (sfruttando l’override del metodo MayDispatchAction), inserire logica prima o dopo il dispatch di una action grazie ai metodi BeforeDispatch e AfterDispatch.

Conclusioni

In questo articolo abbiamo visto come è possibile integrare lo scambio dei dati con un endpoint HTTP all’interno del flusso dati di Fluxor; come questa libreria si integra con strumenti di uso comune per questo particolare pattern quali i Redux DevTools e come possiamo spingere sulla personalizzazione del comportamento della libreria grazie all’uso dei middleware.

L’obiettivo era rendere più chiari i pro ed i contro evidenziati già nell’articolo precedente riguardo la centralizzazione dello store dei dati in una applicazione Blazor con Fluxor.

Come sempre il codice è disponibile nel seguente repository GitHub.

Scritto da: