Blog

Gestión de estado en Blazor: todas las opciones y cuándo usarlas

Blazor
State Management
.NET

Estado del componente, cascading values, la URL, el almacenamiento del navegador, state container, Fluxor y signals: qué ofrece cada uno en Blazor.

Gestión de estado en Blazor: todas las opciones y cuándo usarlas

Toda aplicación Blazor llega al momento en que dos componentes necesitan los mismos datos y pasar parámetros tres niveles hacia abajo deja de ser razonable. Blazor ofrece varias formas de resolverlo y no son intercambiables: unas sobreviven a una recarga, otras solo viven en memoria y alguna vuelve a renderizar media página cada vez que cambia un campo.

Este es el mapa que uso para decidir. Empiezo por la opción más simple que funcione y solo bajo en la lista cuando llego a sus límites.

1. Estado del componente y parámetros

El primer nivel es un campo en el componente y propiedades [Parameter] para los hijos. Si un hijo necesita cambiar algo, lanza un EventCallback y el valor lo guarda el padre.

Counter.razor
<button @onclick="Increment">Clicked @Count times</button>
@code {
[Parameter] public int Count { get; set; }
[Parameter] public EventCallback<int> CountChanged { get; set; }
private Task Increment() => CountChanged.InvokeAsync(Count + 1);
}

Con @bind-Count="count" en el padre se convierte en binding en las dos direcciones. Basta para un formulario, un modal o una lista con filtros. Deja de bastar cuando los datos tienen que cruzar páginas o cuando estás pasando el mismo parámetro por componentes que no lo usan.

2. Cascading values

CascadingValue pone un valor a disposición de todos los descendientes sin pasarlo a mano. Encaja con datos que leen muchos componentes y cambian pocos: el usuario actual, el tema, el tenant.

<CascadingValue Value="currentTheme">
<Router AppAssembly="typeof(App).Assembly" />
</CascadingValue>
[CascadingParameter] public Theme? Theme { get; set; }

Desde .NET 8 también se pueden registrar cascading values a nivel raíz en DI con builder.Services.AddCascadingValue(...), sin envolver el router. La pega es que todos los componentes que reciben el valor se vuelven a renderizar cuando cambia, así que no lo uso para datos que cambian a menudo.

3. La URL

Los filtros, la página actual de una tabla o la pestaña seleccionada van en la URL. Sobreviven a una recarga, el usuario puede compartir el enlace y el botón de atrás del navegador funciona.

@page "/products"
@code {
[SupplyParameterFromQuery] public string? Category { get; set; }
[SupplyParameterFromQuery] public int Page { get; set; } = 1;
}

Para cambiarla, navega con NavigationManager.GetUriWithQueryParameter("page", 2). Es la opción más infravalorada de la lista.

4. Almacenamiento del navegador

Para lo que debe sobrevivir a cerrar la pestaña, como un borrador o las preferencias del usuario, hace falta localStorage o sessionStorage. En Blazor Server y en componentes interactivos de servidor, ProtectedLocalStorage y ProtectedSessionStorage cifran los datos con Data Protection. En WebAssembly se pasa por JS interop o por una librería como Blazored.LocalStorage.

Hay dos cosas a tener en cuenta. El almacenamiento solo está disponible cuando el componente ya es interactivo (después de OnAfterRenderAsync, no durante el prerenderizado). Y todo lo que está en el navegador lo puede leer el usuario, así que no es sitio para nada sensible.

5. El state container

Es el patrón que recomienda Microsoft para compartir estado en memoria entre componentes y páginas. Una clase registrada como servicio scoped guarda el estado y lanza un evento cuando cambia.

CartState.cs
public class CartState
{
private readonly List<CartItem> _items = new();
public IReadOnlyList<CartItem> Items => _items;
public event Action? OnChange;
public void Add(CartItem item)
{
_items.Add(item);
OnChange?.Invoke();
}
}
// Program.cs
builder.Services.AddScoped<CartState>();
@inject CartState Cart
@implements IDisposable
<span>@Cart.Items.Count items</span>
@code {
protected override void OnInitialized() => Cart.OnChange += StateHasChanged;
public void Dispose() => Cart.OnChange -= StateHasChanged;
}

“Scoped” no significa lo mismo en todos los modos de renderizado. En WebAssembly hay un scope por pestaña del navegador, así que se comporta como un singleton para ese usuario. En Blazor Server el scope es el circuito: si se cae la conexión y se crea un circuito nuevo, el estado se pierde salvo que lo persistas en algún sitio.

He usado este patrón en varios proyectos y funciona, pero tiene dos costes que crecen con la aplicación. Cada componente que escucha tiene que suscribirse en OnInitialized y desuscribirse en Dispose, y si se te olvida lo segundo tienes una fuga de memoria. Y cualquier cambio en el contenedor vuelve a renderizar todos los componentes suscritos, aunque solo muestren un campo que no ha cambiado.

6. Persistir el estado del prerenderizado

Si tu aplicación prerenderiza en el servidor, el componente se ejecuta dos veces: una para generar el HTML y otra cuando pasa a ser interactivo. Si no haces nada, los datos que cargaste en la primera pasada se vuelven a cargar en la segunda y el usuario ve un parpadeo.

PersistentComponentState permite serializar el estado en el primer render y recuperarlo en el segundo. En .NET 10 se puede hacer de forma declarativa con el atributo [PersistentState] en una propiedad:

@code {
[PersistentState] public List<Product>? Products { get; set; }
protected override async Task OnInitializedAsync()
{
Products ??= await ProductService.GetAllAsync();
}
}

No sustituye a ninguna de las opciones anteriores. Resuelve un problema concreto del prerenderizado y nada más.

7. Fluxor: Redux en Blazor

Fluxor lleva el patrón Flux/Redux a .NET: estado inmutable, acciones que describen lo que ha pasado, reducers que calculan el estado nuevo y effects para los efectos secundarios, como las llamadas HTTP.

public record CounterState(int Count);
public record IncrementAction;
public static class Reducers
{
[ReducerMethod]
public static CounterState OnIncrement(CounterState state, IncrementAction _) =>
state with { Count = state.Count + 1 };
}

Su punto fuerte es la previsibilidad y las herramientas: todo cambio pasa por una acción, se pueden registrar y la integración con Redux DevTools te deja ver el historial. El precio es la ceremonia. Un campo nuevo puede suponer una acción, un reducer y a veces un effect, repartidos en varios ficheros. Compensa en aplicaciones grandes con un equipo que ya piensa en Redux. En una aplicación pequeña se siente como mucho código para poco.

8. Signals

La última opción es la que acabé construyendo yo. Después de escribir el mismo código de suscripción en varios proyectos con el state container, hice BlazorSignalStore, una librería basada en signals, la misma idea que usan Angular, Solid o Preact.

Cada pieza de estado es un Signal<T> y los valores derivados son Computed<T> que solo se recalculan cuando cambian sus dependencias:

public class CartStore : StoreBase
{
public Signal<int> Quantity { get; } = new(1);
public Signal<decimal> UnitPrice { get; } = new(10m);
public Computed<decimal> Total { get; }
public CartStore()
{
Total = new Computed<decimal>(() => Quantity.Value * UnitPrice.Value, Quantity, UnitPrice);
}
}
// Program.cs
builder.Services.AddSignalStore<CartStore>();

El componente se suscribe a los signals que usa y no hay IDisposable que escribir:

@inject CartStore Store
<p>Total: @total()</p>
@code {
private Func<decimal>? total;
protected override void OnInitialized() => total = this.useSignal(Store.Total);
}

En la demo lo comparo con el state container en un formulario de nueve campos, y la versión con signals hace alrededor de un 50 % menos de renders, porque cambiar un campo solo afecta a lo que depende de él. La comparativa completa está aquí.

Cómo elijo

NecesidadOpción
Estado de un componente o de un padre y sus hijosCampos, parámetros y EventCallback
Datos que leen muchos componentes y cambian pocoCascading values
Filtros, paginación, cualquier cosa que merezca un enlaceLa URL
Sobrevivir a cerrar la pestañaAlmacenamiento del navegador
Compartir estado en memoria entre páginasState container o signals
No cargar los datos dos veces con prerenderizado[PersistentState]
Aplicación grande, equipo acostumbrado a Redux, historial de cambiosFluxor
Formularios o pantallas con mucho estado interdependienteSignals

La mayoría de aplicaciones necesitan dos o tres de estas a la vez. En las mías, la URL guarda los filtros, el almacenamiento guarda las preferencias y un store guarda el resto. El error que más veo es saltar directamente a la opción más compleja para un problema que resolvería un parámetro o la URL.


Comentarios

Aún no hay comentarios. ¡Sé el primero!

Deja un comentario

Tu nombre y tu comentario se publicarán cuando los apruebe. Más información en la política de privacidad.