Blog

El patrón state container en Blazor, con un ejemplo completo

Blazor
State Management
.NET

Cómo construir un state container en Blazor: scoped o singleton, notificar desde otros hilos, evitar fugas de memoria y cuándo dejarlo atrás.

El patrón state container en Blazor, con un ejemplo completo

El state container es el patrón que recomienda Microsoft para compartir estado en memoria entre componentes de Blazor que no son padre e hijo. La idea cabe en una frase: un servicio registrado en DI guarda el estado y lanza un evento cuando cambia, y los componentes interesados se suscriben a ese evento.

La versión básica son diez líneas. Los problemas aparecen en los detalles, así que en este post repaso la versión que pondría en producción y los errores que es fácil cometer con ella.

La versión mínima

public class CounterState
{
public int Count { get; private set; }
public event Action? OnChange;
public void Increment()
{
Count++;
OnChange?.Invoke();
}
}
Program.cs
builder.Services.AddScoped<CounterState>();
@inject CounterState State
@implements IDisposable
<p>@State.Count</p>
<button @onclick="State.Increment">+1</button>
@code {
protected override void OnInitialized() => State.OnChange += StateHasChanged;
public void Dispose() => State.OnChange -= StateHasChanged;
}

Cualquier componente que inyecte CounterState y se suscriba ve el mismo valor. El setter es privado para que la única forma de cambiar el estado sea a través de los métodos del contenedor, que es lo que garantiza que siempre se lanza OnChange.

Scoped, nunca singleton (en Blazor Server)

El ciclo de vida decide quién comparte el estado, y cambia según el modo de renderizado.

En WebAssembly la aplicación se ejecuta en el navegador y hay un contenedor de DI por pestaña. Scoped y singleton acaban comportándose igual: una instancia por usuario y por pestaña.

En Blazor Server, y en componentes interactivos de servidor, scoped significa una instancia por circuito, es decir, por conexión del navegador. Un singleton lo comparten todos los usuarios conectados al servidor. Si registras un carrito como singleton, todos tus usuarios comparten el mismo carrito. Es el bug más grave que permite este patrón, y compila y funciona perfectamente mientras pruebas tú solo.

También en Blazor Server, el circuito puede morir: el usuario cierra el portátil, la conexión se cae demasiado tiempo, el servidor se reinicia. Cuando se crea un circuito nuevo los servicios scoped son nuevos y el estado está vacío. Si ese estado importa, persístelo en base de datos o en el almacenamiento del navegador y reconstrúyelo al arrancar el contenedor.

Notificar desde otro hilo

Si el estado cambia como respuesta a un clic, todo ocurre en el contexto de sincronización del renderer y StateHasChanged funciona. Si cambia desde un timer, un mensaje de SignalR o un Task.Run, en Blazor Server salta una excepción que dice que el hilo actual no está asociado al Dispatcher.

La solución va en el componente, no en el contenedor:

@code {
protected override void OnInitialized() => State.OnChange += OnStateChanged;
private void OnStateChanged() => InvokeAsync(StateHasChanged);
public void Dispose() => State.OnChange -= OnStateChanged;
}

InvokeAsync encola el render en el contexto correcto. Lo uso siempre, aunque hoy todos los cambios vengan de la interfaz, porque el día que alguien añada un timer fallará en producción y no en su máquina.

Estado inmutable

Con propiedades mutables es fácil que un componente haga State.Items.Add(x) sin pasar por el contenedor y sin lanzar OnChange. Exponer un record y sustituirlo en cada cambio cierra esa puerta:

public record CartSnapshot(IReadOnlyList<CartItem> Items, decimal Total);
public class CartState
{
public CartSnapshot Current { get; private set; } = new([], 0m);
public event Action? OnChange;
public void Add(CartItem item)
{
var items = Current.Items.Append(item).ToList();
Current = new CartSnapshot(items, items.Sum(i => i.Price * i.Quantity));
OnChange?.Invoke();
}
}

El total se calcula cuando cambia el estado, no cada vez que un componente lo pinta, y nadie puede modificar la lista desde fuera.

La fuga de memoria que no se ve

El contenedor vive más que los componentes. Si un componente se suscribe y no se desuscribe, el evento del contenedor mantiene una referencia a él y nunca se libera. En Blazor Server, donde el circuito puede durar horas, esto se acumula: cada navegación a la página deja otra instancia colgando, y cada cambio de estado llama a StateHasChanged en componentes que ya no están en pantalla.

La regla es sencilla: cada += en OnInitialized tiene su -= en Dispose, y el componente declara @implements IDisposable. Ayuda tener un componente base que lo haga por ti:

public abstract class StateComponent<TState> : ComponentBase, IDisposable
where TState : class, INotifyStateChanged
{
[Inject] protected TState State { get; set; } = default!;
protected override void OnInitialized() => State.OnChange += OnStateChanged;
private void OnStateChanged() => InvokeAsync(StateHasChanged);
public virtual void Dispose() => State.OnChange -= OnStateChanged;
}
public interface INotifyStateChanged
{
event Action? OnChange;
}

CartState solo tiene que declarar : INotifyStateChanged, y la página pasa a ser @inherits StateComponent<CartState> sin código de suscripción.

Probarlo

Como el contenedor es una clase normal sin dependencias de Blazor, se puede probar con xUnit sin bUnit:

[Fact]
public void Add_updates_total_and_notifies()
{
var state = new CartState();
var notified = false;
state.OnChange += () => notified = true;
state.Add(new CartItem("Book", 20m, 2));
Assert.Equal(40m, state.Current.Total);
Assert.True(notified);
}

Es una de las ventajas frente a guardar el estado dentro de los componentes.

Cuándo deja de bastar

El state container tiene un límite que ningún truco arregla: hay un único evento para todo el contenedor. Si una página muestra el total del carrito en la cabecera, la lista en el cuerpo y un contador en un panel lateral, añadir un producto vuelve a renderizar los tres, aunque alguno muestre algo que no ha cambiado. Con un contenedor por funcionalidad no pasa nada. Con un formulario de veinte campos en un solo contenedor, cada pulsación de tecla vuelve a renderizar el formulario entero.

En ese punto hay dos salidas. Puedes partir el contenedor en otros más pequeños, o pasar a un enfoque con notificaciones más finas. Eso es lo que me llevó a escribir BlazorSignalStore: cada propiedad es un signal y solo se vuelven a renderizar los componentes que la leen. Si quieres ver todas las opciones juntas, las comparo en Gestión de estado en Blazor: todas las opciones y cuándo usarlas.


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.