Blazor Web Apps en profundidad: Estado, ciclo de vida y pitfalls

En el artículo anterior vimos cómo elegir y aplicar los diferentes Render Modes. Parecía bastante sencillo: pones @rendermode InteractiveAuto y listo. Pero en cuanto empiezas a construir componentes reales, con llamadas a bases de datos, APIs o JavaScript, aparece el primer susto:

«Mi componente está ejecutando las peticiones dos veces… y no es un bug.»

En esta entrega vamos a entender por qué ocurren estos problemas, cómo se comporta el estado en este nuevo modelo híbrido y, lo más importante, cómo solucionarlos con las herramientas que nos ofrece .NET 10.

Checklist rápida

Si ya tienes experiencia con Blazor y quieres saber si este artículo te va a servir, aquí va la esencia:

  • Si usas Auto –> Implementa siempre PersistentComponentState cuando hagas llamadas a bases de datos o APIs.
  • Si llamas a JavaScript –> Hazlo solo en OnAfterRenderAsync.
  • Si usas Server –> Monitorea el consumo de RAM en producción: los singletons son compartidos entre todos los usuarios.
  • Si fallan los parámetros –> Revisa que estés enviando solo objetos serializables entre render modes.
  • Si necesitas saber dónde estás ejecutándote –> Usa RendererInfo de forma defensiva antes de tocar APIs del navegador.

Si alguno de estos puntos te genera dudas, sigue leyendo.

1. El problema oculto del prerendering y el ciclo de vida

Cuando usamos cualquier modo interactivo (Server, WebAssembly o Auto), Blazor activa por defecto el prerendering. El servidor ejecuta tu componente, genera el HTML estático y lo envía al navegador para que el usuario vea la interfaz inmediatamente.

Poco después, la interactividad se activa (ya sea mediante SignalR o WebAssembly) y el componente se reinicializa en su nuevo entorno. Esto es lo que genera la doble ejecución que tanto confunde al principio.

El flujo completo con InteractiveAuto se ve así:

flowchart TD
    A(["🌐 Petición inicial del usuario"])

    subgraph SSR["Fase 1 — Static SSR (Prerender)"]
        B["Servidor ejecuta el componente\nGenera HTML estático"]
        C(["OnInitializedAsync se ejecuta\n1ª vez en servidor"])
        D["HTML + estado serializado\nenviado al navegador"]
    end

    subgraph SERVER["Fase 2 — Interactive Server (1ª visita)"]
        E["WebSocket SignalR se activa\nComponente se reinicializa"]
        F(["OnInitializedAsync se ejecuta\n2ª vez via SignalR"])
        G["Estado recuperado desde HTML\nsin nueva llamada a la API"]
    end

    subgraph WASM["Fase 3 — Interactive WASM (visitas futuras)"]
        H[".NET WASM descargado\nen background"]
        I(["OnInitializedAsync corre\nen el browser del cliente"])
        J["Sin servidor necesario\nPura ejecución en cliente"]
    end

    A --> B
    B --> C
    C --> D
    D --> E
    E --> F
    F --> G
    G -.->|"Bundle WASM\ndescargado en bg"| H
    H --> I
    I --> J
    

OnInitializedAsync se ejecuta dos veces

Si dentro de OnInitializedAsync haces await _api.GetData(), esa llamada se ejecutará una vez en el servidor (prerender) y otra cuando se active la interactividad. El resultado: doble carga, posible flickering y experiencia degradada.

La solución oficial y elegante de Microsoft es PersistentComponentState.

2. Manejo de estado: PersistentComponentState en acción

La idea es sencilla pero poderosa: durante el prerender guardamos el estado en el HTML y, cuando el componente interactivo despierta, lo recupera directamente en lugar de volver a consultar la fuente de datos.

Así lo aplicamos en la página /analytics de nuestra Task Manager App:

@page "/analytics"
@using TaskManagerApp.Client.Models
@using TaskManagerApp.Client.Services
@inject ITaskService TaskService
@inject PersistentComponentState ApplicationState
@implements IDisposable
@rendermode InteractiveAuto

@* ... HTML de la página ... *@

@code {
    private List<TaskItem>? tasks;
    private PersistingComponentStateSubscription persistingSubscription;

    protected override async Task OnInitializedAsync()
    {
        persistingSubscription = ApplicationState.RegisterOnPersisting(PersistData);

        if (!ApplicationState.TryTakeFromJson<List<TaskItem>>("tasks", out var restored))
        {
            // Primera vez (prerender) → vamos a buscar los datos
            tasks = await TaskService.GetTasksAsync();
        }
        else
        {
            // Al activar interactividad → recuperamos el estado ya serializado
            tasks = restored;
        }
    }

    private Task PersistData()
    {
        if (tasks != null)
        {
            ApplicationState.PersistAsJson("tasks", tasks);
        }
        return Task.CompletedTask;
    }

    public void Dispose()
    {
        persistingSubscription.Dispose();
    }
}

Trade-off importante: No abuses de esto. Guardar objetos muy pesados aumenta el tamaño del HTML inicial. Úsalo para datos críticos de carga inicial y deja las listas grandes para carga bajo demanda o paginación.

3. RendererInfo: ¿Dónde diablos estoy ejecutándome?

Con InteractiveAuto tu código puede ejecutarse en tres contextos distintos: servidor estático (prerender), servidor interactivo (SignalR) y cliente (WebAssembly). A veces necesitas saber exactamente dónde estás.

.NET 9+ nos da RendererInfo para esto.

Uso para depuración

<div class="alert alert-info">
    <strong>Entorno actual:</strong> @RendererInfo.Name <br />
    <strong>Interactivo:</strong> @(RendererInfo.IsInteractive ? "Sí" : "No (Prerendering)")
</div>

Uso defensivo (el realmente útil)

Evita errores clásicos como intentar acceder a localStorage durante el prerender:

Malo (falla en prerender):

@code {
    protected override async Task OnInitializedAsync()
    {
        // System.InvalidOperationException: JavaScript interop calls cannot be issued at this time.
        var theme = await JS.InvokeAsync<string>("localStorage.getItem", "theme");
    }
}

Versión segura con RendererInfo:

@code {
    protected override async Task OnAfterRenderAsync(bool firstRender)
    {
        if (firstRender && RendererInfo.IsInteractive)
        {
            // Solo llegamos aquí cuando hay un browser real ejecutando el código
            var theme = await JS.InvokeAsync<string>("localStorage.getItem", "theme");
            ApplyTheme(theme);
        }
    }
}

Regla de oro: RendererInfo.IsInteractive es tu guardia de seguridad antes de cualquier operación que requiera un navegador real.

4. Pitfalls importantes (dónde más vas a tropezar)

Jerarquías con distintos render modes

Un componente hijo con InteractiveWebAssembly no puede recibir por parámetro un objeto complejo (como una instancia de base de datos o un servicio con dependencias no serializables) desde un componente padre en modo Static SSR. Los parámetros que cruzan entre SSR e Interactivo deben ser serializables a JSON.

Llamadas a JavaScript fuera de OnAfterRenderAsync

Este es uno de los errores más comunes y frustrantes:

Código que falla:

protected override async Task OnInitializedAsync()
{
    await JS.InvokeVoidAsync("miFuncion"); // Explota en prerender
}

Código correcto:

protected override async Task OnAfterRenderAsync(bool firstRender)
{
    if (firstRender)
    {
        await JS.InvokeVoidAsync("miFuncion"); // Seguro: el DOM ya existe
    }
}

Regla de oro: Cualquier llamada a JavaScript (IJSRuntime) debe hacerse en OnAfterRenderAsync(bool firstRender).

Estado en InteractiveServer

Aunque InteractiveServer es muy rápido, todo el estado vive en la memoria RAM del servidor mientras la conexión WebSocket permanezca abierta.

En la vista de /tasks de nuestro proyecto dejamos esta nota recordatorio:

protected override async Task OnInitializedAsync()
{
    // En InteractiveServer, el estado se mantiene en memoria en el servidor para esta conexión SignalR.
    // Ojo: Si el usuario recarga la página, esta instancia del componente se destruye y el estado interno se pierde.
    tasks = await TaskService.GetTasksAsync();
}

Si el servidor se reinicia o la conexión se pierde, los usuarios pierden su estado. Siempre diseña pensando que la conexión SignalR puede romperse en cualquier momento.

5. Performance en el mundo real

Cuando aplicas correctamente el manejo de estado y ciclo de vida, obtienes mejoras reales de rendimiento:

  • WASM Payload: Con Auto, el peso inicial de .NET no penaliza al usuario porque la primera carga se renderiza instantáneamente desde el servidor gracias al prerendering. El bundle de WASM se descarga en segundo plano.
  • SignalR Scaling: Al combinar páginas Static SSR con solo algunas páginas Interactive Server, reduces significativamente el consumo de memoria del servidor. No todos los usuarios necesitan un WebSocket abierto.
  • Caching: En Static SSR el Output Caching tradicional vuelve a ser extremadamente útil con [OutputCache]. Esto no es posible en componentes InteractiveServer porque son stateful.

6. Reglas Prácticas Finales

Para que no tengas que recordar todo esto cada día, aquí tienes la checklist completa (disponible también al principio del artículo):

  • Si usas Auto –> Implementa siempre PersistentComponentState si tienes llamadas a bases de datos o APIs lentas.
  • Si llamas a JS –> Hazlo exclusivamente en OnAfterRenderAsync y protégelo con firstRender o RendererInfo.IsInteractive.
  • Si usas Server –> Monitoriza el consumo de RAM en producción y recuerda que los singletons son compartidos entre todos los usuarios.
  • Si fallan los parámetros –> Revisa si estás enviando objetos no serializables desde un render mode a otro.
  • Si necesitas APIs de browser –> Usa RendererInfo.IsInteractive como guardia defensiva antes de llamarlas.

Con estas bases técnicas tu aplicación Blazor será mucho más rápida, estable y predecible.

Y ahora que viene?

En el próximo artículo de la serie subiremos un nivel más y hablaremos de “Arquitectura real con Blazor Web Apps”: cómo organizar proyectos grandes, estructurar servicios que funcionen correctamente en todos los render modes y manejar autenticación de forma robusta.

Todos los ejemplos de este artículo están extraídos de la Task Manager App, la aplicación de referencia que acompaña esta serie. Si quieres explorar el código completo, ver cómo encajan todas las piezas o usarlo como base para tus proyectos, el repositorio está disponible en GitHub: TaskManagerApp

Tema Relacionado:

Deja una respuesta

Tu dirección de correo electrónico no será publicada. Los campos obligatorios están marcados con *

Este sitio usa Akismet para reducir el spam. Aprende cómo se procesan los datos de tus comentarios.