Quien busca Redux en Blazor acaba en Fluxor, la librería Flux/Redux más usada en .NET. Yo escribí BlazorSignalStore, que resuelve el mismo problema con signals en lugar de acciones y reducers. Está claro que no soy neutral, así que en vez de dar una opinión voy a construir la misma funcionalidad con las dos y enseñar el código.
La funcionalidad: una lista de productos cargada desde una API, con indicador de carga, un filtro de texto y un contador de productos visibles.
Fluxor
La configuración va en Program.cs, más un componente que inicializa el store:
builder.Services.AddFluxor(options => options .ScanAssemblies(typeof(Program).Assembly) .UseReduxDevTools());<!-- MainLayout.razor --><Fluxor.Blazor.Web.StoreInitializer />El estado es un record inmutable marcado como feature:
[FeatureState]public record ProductsState{ public bool IsLoading { get; init; } public IReadOnlyList<Product> Products { get; init; } = []; public string Filter { get; init; } = "";
private ProductsState() { }}Cada cosa que puede pasar es una acción, y cada acción tiene un reducer que devuelve el estado nuevo:
public record LoadProductsAction;public record LoadProductsSuccessAction(IReadOnlyList<Product> Products);public record SetFilterAction(string Filter);
public static class ProductsReducers{ [ReducerMethod(typeof(LoadProductsAction))] public static ProductsState OnLoad(ProductsState state) => state with { IsLoading = true };
[ReducerMethod] public static ProductsState OnLoadSuccess(ProductsState state, LoadProductsSuccessAction action) => state with { IsLoading = false, Products = action.Products };
[ReducerMethod] public static ProductsState OnSetFilter(ProductsState state, SetFilterAction action) => state with { Filter = action.Filter };}La llamada a la API no puede ir en un reducer porque los reducers deben ser puros, así que va en un effect:
public class ProductsEffects(IProductApi api){ [EffectMethod(typeof(LoadProductsAction))] public async Task HandleLoad(IDispatcher dispatcher) { var products = await api.GetAllAsync(); dispatcher.Dispatch(new LoadProductsSuccessAction(products)); }}Y el componente hereda de FluxorComponent, que se encarga de la suscripción:
@inherits Fluxor.Blazor.Web.Components.FluxorComponent@inject IState<ProductsState> State@inject IDispatcher Dispatcher
<input value="@State.Value.Filter" @oninput="@(e => Dispatcher.Dispatch(new SetFilterAction(e.Value?.ToString() ?? "")))" />
@if (State.Value.IsLoading) { <p>Loading...</p> }
<p>@Visible.Count products</p>@foreach (var p in Visible) { <ProductCard Product="p" /> }
@code { private IReadOnlyList<Product> Visible => State.Value.Products.Where(p => p.Name.Contains(State.Value.Filter, StringComparison.OrdinalIgnoreCase)).ToList();
protected override void OnInitialized() { base.OnInitialized(); Dispatcher.Dispatch(new LoadProductsAction()); }}Cuatro tipos de piezas (estado, acciones, reducers, effects), normalmente en cuatro ficheros.
BlazorSignalStore
La configuración es una línea:
builder.Services.AddSignalStore<ProductsStore>();El estado, los valores derivados y las operaciones viven en el store:
public class ProductsStore : StoreBase{ private readonly IProductApi api;
public Signal<bool> IsLoading { get; } = new(false); public Signal<IReadOnlyList<Product>> Products { get; } = new([]); public Signal<string> Filter { get; } = new(""); public Computed<IReadOnlyList<Product>> Visible { get; }
public ProductsStore(IProductApi api) { this.api = api; Visible = new Computed<IReadOnlyList<Product>>(() => Products.Value.Where(p => p.Name.Contains(Filter.Value, StringComparison.OrdinalIgnoreCase)).ToList(), Products, Filter); }
public async Task LoadAsync() { IsLoading.Value = true; Products.Value = await api.GetAllAsync(); IsLoading.Value = false; }}El componente se suscribe a los signals que lee:
@inject ProductsStore Store
<input value="@filter()" @oninput="@(e => Store.Filter.Value = e.Value?.ToString() ?? "")" />
@if (isLoading()) { <p>Loading...</p> }
<p>@visible().Count products</p>@foreach (var p in visible()) { <ProductCard Product="p" /> }
@code { private Func<string>? filter; private Func<bool>? isLoading; private Func<IReadOnlyList<Product>>? visible;
protected override async Task OnInitializedAsync() { filter = this.useSignal(Store.Filter); isLoading = this.useSignal(Store.IsLoading); visible = this.useSignal(Store.Visible); await Store.LoadAsync(); }}Una clase para el store y nada más. La llamada asíncrona es un método normal.
Qué cambia en la práctica
Cantidad de código. Fluxor necesita un tipo por cada evento y una función por cada transición. A cambio, el estado solo puede cambiar en los reducers y todo lo que pasa tiene nombre. En BlazorSignalStore cualquier código con acceso al store puede escribir un signal, lo que es más corto y también más fácil de usar mal en un equipo grande.
Renders. Un FluxorComponent se vuelve a renderizar cuando cambia el estado de la feature, sea cual sea la parte que ha cambiado. Fluxor tiene IStateSelection<TState, TValue> para suscribirse solo a una parte del estado y evitarlo, pero hay que usarlo de forma explícita. En BlazorSignalStore la suscripción es por signal por defecto, y los Computed solo se recalculan cuando cambia alguna de sus dependencias. En la demo de la librería, con un formulario de nueve campos, eso supuso alrededor de un 50 % menos de renders que el state container de Microsoft. Fluxor con selectores no lo he medido, así que ahí no doy cifra.
Trabajo asíncrono y efectos secundarios. Fluxor los separa en effects, lo que mantiene los reducers fáciles de probar y centraliza las llamadas al exterior. En BlazorSignalStore son métodos del store, que es más directo pero deja la disciplina en tus manos.
Herramientas. Aquí Fluxor gana claramente. La integración con Redux DevTools te deja ver cada acción, el estado antes y después, y volver atrás en el tiempo. Tiene middleware para logging y años de uso en producción. BlazorSignalStore es una librería joven, sin DevTools y con muchos menos usuarios.
Curva de aprendizaje. Quien haya usado Redux, NgRx o Vuex reconoce Fluxor enseguida. Los signals se parecen más a lo que hoy ofrecen Angular, Solid o Vue, y a quien no ha usado ninguno de los dos los signals le suelen costar menos.
Cuándo usaría cada uno
Elegiría Fluxor para una aplicación grande con varias personas tocando el mismo estado, donde poder seguir cada cambio y tener DevTools vale más que escribir menos código, o para un equipo que ya trabaja con Redux en otros front ends.
Elegiría BlazorSignalStore para formularios y pantallas con mucho estado interdependiente, donde los renders importan, para aplicaciones pequeñas y medianas en las que la ceremonia de Fluxor no compensa, y cuando quiero que los valores derivados se mantengan al día sin pensar en ello.
Y para muchas aplicaciones, ninguno de los dos: basta con un state container o con la URL. Repaso todas las opciones en Gestión de estado en Blazor: todas las opciones y cuándo usarlas.
La librería está en NuGet (dotnet add package BlazorSignalStore) y el código y la demo están en GitHub.
Comentarios
Aún no hay comentarios. ¡Sé el primero!
Deja un comentario