Introducción
BlazorSignalStore es una solución moderna para la gestión de estado en aplicaciones Blazor. Después de trabajar en varios proyectos Blazor utilizando el patrón Container State propuesto por Microsoft, noté cantidades significativas de código repetitivo: suscripciones manuales en OnInitialized, implementaciones de IDisposable en cada componente suscrito, y la necesidad de disparar manualmente los cambios de estado. Además, cualquier cambio de propiedad causaba que componentes enteros se re-renderizaran innecesariamente.
Aquí es donde brilla BlazorSignalStore: proporciona reactividad granular que solo re-renderiza las partes específicas de la UI que realmente cambiaron.
Patrón Container State
Microsoft recomienda usar una clase como contenedor e inyectarla en el contenedor de inyección de dependencias como Scoped.
builder.Services.AddScoped<ContainerStateService>();Necesitas una clase que describa el estado y las acciones que se pueden realizar sobre él:
public record FormContainerState { public PersonalInfo PersonalInfo { get; init; } = new(); public Address Address { get; init; } = new(); public UserPreferencesData Preferences { get; init; } = new(); public bool IsSubmitting { get; init; } = false; public SubmissionResult? SubmissionResult { get; init; } = null;
// Lógica de validación dentro del contenedor public bool IsValid() { return PersonalInfo.IsValid() && Address.IsValid() && Preferences.IsValid(); }
public bool CanSubmit() { return IsValid() && !IsSubmitting; }
public List<string> GetValidationErrors() { var errors = new List<string>(); errors.AddRange(PersonalInfo.GetValidationErrors()); errors.AddRange(Address.GetValidationErrors()); errors.AddRange(Preferences.GetValidationErrors()); return errors; }
public double GetCompletionPercentage() { var totalFields = 9; // Total de campos requeridos var completedFields = 0;
if (!string.IsNullOrWhiteSpace(PersonalInfo.FirstName)) completedFields++; if (!string.IsNullOrWhiteSpace(PersonalInfo.LastName)) completedFields++; if (!string.IsNullOrWhiteSpace(PersonalInfo.Email)) completedFields++; if (!string.IsNullOrWhiteSpace(Address.Street)) completedFields++; if (!string.IsNullOrWhiteSpace(Address.City)) completedFields++; if (!string.IsNullOrWhiteSpace(Address.PostalCode)) completedFields++; if (!string.IsNullOrWhiteSpace(Address.Country)) completedFields++; if (!string.IsNullOrWhiteSpace(Preferences.Theme)) completedFields++; if (Preferences.EmailNotifications || Preferences.SmsNotifications) completedFields++;
return (double)completedFields / totalFields * 100; } }El contenedor de estado declara acciones y notifica a los suscriptores cuando ocurren cambios:
public class ContainerStateService { private FormContainerState _formState = new();
public event Action? OnChange;
public FormContainerState FormState => _formState;
// Propiedades derivadas del estado del contenedor public bool IsFormValid => _formState.IsValid(); public bool CanSubmit => _formState.CanSubmit(); public List<string> ValidationErrors => _formState.GetValidationErrors(); public double FormCompletionPercentage => _formState.GetCompletionPercentage();
// Acciones que modifican el estado del contenedor public void UpdatePersonalInfo(string firstName, string lastName, string email) { _formState = _formState with { PersonalInfo = _formState.PersonalInfo with { FirstName = firstName, LastName = lastName, Email = email } }; NotifyStateChanged(); }
public void UpdateAddress(string street, string city, string postalCode, string country) { _formState = _formState with { Address = _formState.Address with { Street = street, City = city, PostalCode = postalCode, Country = country } }; NotifyStateChanged(); }
public void UpdatePreferences(bool emailNotifications, bool smsNotifications, string theme) { _formState = _formState with { Preferences = _formState.Preferences with { EmailNotifications = emailNotifications, SmsNotifications = smsNotifications, Theme = theme } }; NotifyStateChanged(); }
private void NotifyStateChanged() => OnChange?.Invoke(); }Para usarlo, inyectas el servicio/contenedor de estado e implementas IDisposable para cancelar suscripciones cuando termina el ciclo de vida del componente:
<!-- ContainerExample.razor -->@page "/container-state"@using BlazorSignalStore.Demo.Store@inject ContainerStateService ContainerStateService@implements IDisposable
<PageTitle>Container State Demo - BlazorSignalStore</PageTitle>
<div class="container-fluid"> <div class="row"> <div class="col-12"> <h1 class="display-4 text-center mb-4">📦 Demo Patrón Container State</h1> <p class="lead text-center mb-5"> Esta demo muestra el patrón Container State recomendado por Microsoft para aplicaciones Blazor. En lugar de gestionar propiedades de estado individuales, todo el estado relacionado se agrupa en un solo contenedor. </p> </div> </div>
<!-- Barra de Progreso --> <div class="row mb-4"> <div class="col-12"> <div class="card shadow-sm"> <div class="card-header bg-primary text-white"> <h5 class="mb-0">📊 Progreso del Formulario</h5> </div> <div class="card-body"> <div class="progress mb-2" style="height: 25px;"> <div class="progress-bar progress-bar-striped @(ContainerStateService.FormCompletionPercentage == 100 ? "bg-success" : "")" style="width: @ContainerStateService.FormCompletionPercentage%"> @ContainerStateService.FormCompletionPercentage.ToString("F0")% </div> </div> <small class="text-muted">Completa todos los campos para habilitar el envío</small> </div> </div> </div> </div>
<!-- Ejemplo de Formulario --> <div class="row"> <div class="col-md-6"> <input type="text" class="form-control mb-2" placeholder="Nombre" value="@ContainerStateService.FormState.PersonalInfo.FirstName" @onchange="@(e => UpdatePersonalInfo(e.Value?.ToString() ?? "", ContainerStateService.FormState.PersonalInfo.LastName, ContainerStateService.FormState.PersonalInfo.Email))" /> </div> <div class="col-md-6"> <input type="text" class="form-control mb-2" placeholder="Apellido" value="@ContainerStateService.FormState.PersonalInfo.LastName" @onchange="@(e => UpdatePersonalInfo(ContainerStateService.FormState.PersonalInfo.FirstName, e.Value?.ToString() ?? "", ContainerStateService.FormState.PersonalInfo.Email))" /> </div> </div></div>
@code { protected override void OnInitialized() { ContainerStateService.OnChange += StateHasChanged; }
private void UpdatePersonalInfo(string firstName, string lastName, string email) { ContainerStateService.UpdatePersonalInfo(firstName, lastName, email); }
private void UpdateAddress(string street, string city, string postalCode, string country) { ContainerStateService.UpdateAddress(street, city, postalCode, country); }
private void UpdatePreferences(bool emailNotifications, bool smsNotifications, string theme) { ContainerStateService.UpdatePreferences(emailNotifications, smsNotifications, theme); }
public void Dispose() { ContainerStateService.OnChange -= StateHasChanged; }}El Problema con Container State
El problema con esta solución es que siempre tenemos código repetitivo para suscribirnos y desuscribirnos de los cambios de estado. El problema principal es que al recibir cambios en cualquier propiedad, el componente completo se re-renderiza. Esto significa que si tenemos un formulario y modificamos el nombre, todo el componente se re-renderizará debido a la suscripción al cambio de estado.
Aquí es donde brilla la reactividad de Signals, ya que solo las partes específicas que han sido modificadas se re-renderizarán parcialmente.
BlazorSignalStore
Esta implementación simplifica la experiencia de desarrollo eliminando el código boilerplate y optimizando la solución para evitar re-renderizados cuando se modifica una sola propiedad del estado.
builder.Services.AddSignalStore<SignalFormStore>();Declaración del Store con propiedades agrupadas en Signals y propiedades Computadas:
using BlazorSignalStore.Core;
namespace BlazorSignalStore.Demo.Store{ /// <summary> /// Un store de formulario demostrando reactividad granular con Signals. /// Cada propiedad es su propio Signal, permitiendo actualizaciones precisas de la UI. /// </summary> public class SignalFormStore : StoreBase { // Signals de Información Personal public Signal<string> FirstName { get; } = new(""); public Signal<string> LastName { get; } = new(""); public Signal<string> Email { get; } = new("");
// Signals de Dirección public Signal<string> Street { get; } = new(""); public Signal<string> City { get; } = new(""); public Signal<string> PostalCode { get; } = new(""); public Signal<string> Country { get; } = new("");
// Signals de Preferencias public Signal<bool> EmailNotifications { get; } = new(false); public Signal<bool> SmsNotifications { get; } = new(false); public Signal<string> Theme { get; } = new("light");
// Propiedades computadas para validación y progreso public Computed<bool> IsPersonalInfoValid { get; } public Computed<bool> IsAddressValid { get; } public Computed<bool> IsFormValid { get; } public Computed<double> FormProgress { get; }
public SignalFormStore() { IsPersonalInfoValid = new Computed<bool>(() => !string.IsNullOrWhiteSpace(FirstName.Value) && !string.IsNullOrWhiteSpace(LastName.Value) && !string.IsNullOrWhiteSpace(Email.Value), FirstName, LastName, Email);
IsAddressValid = new Computed<bool>(() => !string.IsNullOrWhiteSpace(Street.Value) && !string.IsNullOrWhiteSpace(City.Value) && !string.IsNullOrWhiteSpace(PostalCode.Value) && !string.IsNullOrWhiteSpace(Country.Value), Street, City, PostalCode, Country);
IsFormValid = new Computed<bool>(() => IsPersonalInfoValid.Value && IsAddressValid.Value, IsPersonalInfoValid, IsAddressValid);
FormProgress = new Computed<double>(() => { var completedFields = 0; var totalFields = 9;
if (!string.IsNullOrWhiteSpace(FirstName.Value)) completedFields++; if (!string.IsNullOrWhiteSpace(LastName.Value)) completedFields++; if (!string.IsNullOrWhiteSpace(Email.Value)) completedFields++; if (!string.IsNullOrWhiteSpace(Street.Value)) completedFields++; if (!string.IsNullOrWhiteSpace(City.Value)) completedFields++; if (!string.IsNullOrWhiteSpace(PostalCode.Value)) completedFields++; if (!string.IsNullOrWhiteSpace(Country.Value)) completedFields++; if (!string.IsNullOrWhiteSpace(Theme.Value)) completedFields++; if (EmailNotifications.Value || SmsNotifications.Value) completedFields++;
return (double)completedFields / totalFields * 100; }, FirstName, LastName, Email, Street, City, PostalCode, Country, Theme, EmailNotifications, SmsNotifications); } }}El uso en el componente se simplifica. Solo inyectas el Store y añades los Signals en OnInitialized. Los propios Signals manejan la lógica de renderizado y la notificación de cambios automáticamente:
<!-- SignalFormExample.razor -->@page "/signal-form"@using BlazorSignalStore.Demo.Store@using BlazorSignalStore.Core@inject SignalFormStore SignalFormStore
<PageTitle>Demo Formulario Signal - BlazorSignalStore</PageTitle>
<div class="container-fluid"> <div class="row"> <div class="col-12"> <h1 class="display-4 text-center mb-4">⚡ Demo Formulario Signal</h1> <p class="lead text-center mb-5"> Esta demo muestra la reactividad granular de BlazorSignalStore: solo las partes específicas de la UI que dependen de señales modificadas se re-renderizarán. </p> </div> </div>
<!-- Barra de Progreso --> <div class="row mb-4"> <div class="col-12"> <div class="card shadow-sm"> <div class="card-header bg-success text-white"> <h5 class="mb-0">📊 Progreso del Formulario (Reactivo)</h5> </div> <div class="card-body"> <div class="progress mb-2" style="height: 25px;"> <div class="progress-bar progress-bar-striped @(formProgress() == 100 ? "bg-success" : "")" style="width: @formProgress()%"> @formProgress().ToString("F0")% </div> </div> <small class="text-muted">Completa todos los campos para habilitar el envío</small> </div> </div> </div> </div>
<!-- Campos del Formulario --> <div class="row"> <div class="col-md-6"> <input type="text" class="form-control mb-2" placeholder="Nombre" value="@firstName()" @onchange="@(e => SignalFormStore.FirstName.Value = e.Value?.ToString() ?? "")" /> </div> <div class="col-md-6"> <input type="text" class="form-control mb-2" placeholder="Apellido" value="@lastName()" @onchange="@(e => SignalFormStore.LastName.Value = e.Value?.ToString() ?? "")" /> </div> </div></div>
@code { private Func<string>? firstName; private Func<string>? lastName; private Func<string>? email; private Func<double>? formProgress; private Func<bool>? isFormValid;
protected override void OnInitialized() { firstName = this.useSignal(SignalFormStore.FirstName); lastName = this.useSignal(SignalFormStore.LastName); email = this.useSignal(SignalFormStore.Email); formProgress = this.useSignal(SignalFormStore.FormProgress); isFormValid = this.useSignal(SignalFormStore.IsFormValid); }}Comparación de Rendimiento
Nuestra aplicación de demostración incluye una comparación lado a lado que demuestra los beneficios de rendimiento de BlazorSignalStore:
Patrón Container State:
- Re-renderizados: Cuando cualquier campo cambia, todo el componente se re-renderiza
- Rendimiento: ~100% tasa de re-renderizado para interacciones de formulario
- Código: Requiere gestión de suscripción manual e implementación de IDisposable
BlazorSignalStore:
- Re-renderizados: Solo los elementos específicos de la UI que dependen de la señal cambiada se re-renderizan
- Rendimiento: ~50% menos re-renderizados comparado con Container State
- Código: Gestión automática de suscripciones, sin necesidad de boilerplate
Ventajas Clave de BlazorSignalStore
🚀 Beneficios de Rendimiento
- Reactividad Granular: Solo las partes afectadas de la UI se re-renderizan
- Uso Reducido de CPU: Menos ciclos de renderizado innecesarios
- Mejor Experiencia de Usuario: Interacciones más fluidas en formularios complejos
🛠️ Experiencia de Desarrollador
- Menos Boilerplate: Sin necesidad de gestión manual de suscripciones
- Sin IDisposable: Limpieza automática manejada por la librería
- Seguridad de Tipos: Tipado fuerte con Signal<T> y Computed<T> genéricos
- API Intuitiva: Similar a frameworks reactivos modernos
📊 Escalabilidad
- Formularios Grandes: Los beneficios de rendimiento aumentan con la complejidad del formulario
- Estado Complejo: Fácil de gestionar estado interdependiente con señales Computadas
- Eficiencia de Memoria: La limpieza automática de suscripciones previene fugas de memoria
Cuándo Usar Cada Patrón
Usa Container State Cuando:
- ✅ Aplicaciones simples con necesidades mínimas de gestión de estado
- ✅ Se requiere seguir patrones estrictos de Microsoft
- ✅ El equipo no está familiarizado con conceptos de programación reactiva
Usa BlazorSignalStore Cuando:
- ✅ Construyes formularios complejos con muchos campos
- ✅ La optimización del rendimiento es importante
- ✅ Quieres una gestión de estado reactiva moderna
- ✅ Reducir el código boilerplate es una prioridad
- ✅ Construyes aplicaciones de gran escala
Conclusión
Mientras que el patrón Container State de Microsoft proporciona una base sólida para la gestión de estado en Blazor, BlazorSignalStore ofrece ventajas significativas tanto en rendimiento como en experiencia de desarrollador:
- ~50% menos re-renderizados en escenarios típicos de formularios
- Elimina código boilerplate para la gestión de suscripciones
- Proporciona reactividad granular similar a frameworks frontend modernos
- Mantiene la seguridad de tipos y sigue las convenciones de .NET
- Escala mejor para aplicaciones complejas
BlazorSignalStore trae lo mejor de la programación reactiva a Blazor mientras mantiene la experiencia de desarrollo familiar de C#. Es particularmente valioso para aplicaciones con formularios complejos, datos en tiempo real o escenarios críticos de rendimiento.
La librería prueba que no tienes que elegir entre rendimiento y experiencia de desarrollador - puedes tener ambos.
Prueba BlazorSignalStore hoy y experimenta la diferencia que hace la reactividad granular en tus aplicaciones Blazor!
dotnet add package BlazorSignalStorePara más ejemplos y documentación, visita nuestro repositorio de GitHub.