Introduction

Dans une application Blazor en rendu interactif serveur, je bénéficie d'un modèle de programmation très confortable : mes composants s'exécutent côté serveur et l'interface reste synchronisée via le circuit SignalR. Mais ce confort a un coût : l'état saisi par l'utilisateur vit souvent dans la mémoire du circuit.

J'ai un exemple très concret dans mes applications métier : un utilisateur prépare une commande, ajoute plusieurs lignes, remplit une référence et passe sur son téléphone pour répondre à un appel. Si l'onglet est suspendu trop longtemps ou si le réseau bascule, je ne veux pas qu'il retrouve un formulaire vide.

Avec .NET 10, Blazor Web App apporte la persistance de l'état de circuit pour le mode InteractiveServer. Je peux désormais déclarer les propriétés à conserver, suspendre un circuit devenu inactif puis le reprendre en recréant un nouveau circuit avec les données utiles.

Dans cet article, je vais montrer :

  1. Ce que Blazor persiste réellement et ce qu'il ne persiste pas.
  2. Comment conserver un formulaire métier avec [PersistentState].
  3. Comment persister l'état porté par un service Scoped.
  4. Comment choisir entre stockage en mémoire et cache distribué.
  5. Comment mettre en pause un circuit lorsque l'onglet n'est plus visible.
  6. Les règles que j'applique pour ne pas transformer cette fonctionnalité en piège de performance ou de sécurité.

1. Pourquoi un circuit Blazor peut perdre le travail en cours

Dans une Blazor Web App en InteractiveServer, un circuit contient notamment :

  • les instances de composants actives ;
  • les valeurs de leurs propriétés ;
  • les services Scoped attachés à la session interactive ;
  • les informations nécessaires pour produire les prochaines mises à jour de l'interface.

Quand la connexion est brièvement interrompue, Blazor tente habituellement de reconnecter le navigateur au même circuit. Ce mécanisme est efficace, mais je ne peux pas compter sur lui indéfiniment : le serveur doit libérer les circuits déconnectés, une instance peut être redémarrée lors d'un déploiement, et un terminal mobile peut mettre un onglet en veille plus longtemps que prévu.

La nouveauté .NET 10 consiste à extraire un sous-ensemble choisi de l'état applicatif, à le sérialiser, puis à le réinjecter lors de la reprise du circuit. Je ne sauvegarde donc pas l'arbre de rendu ni les composants eux-mêmes : je sauvegarde les données nécessaires pour reconstruire une expérience cohérente.

Les limites à avoir en tête

Avant d'écrire une ligne de code, je pose ces limites clairement :

  • La persistance de circuit concerne uniquement le rendu InteractiveServer.
  • Un rechargement complet de la page ne restaure pas cet état de circuit.
  • Les propriétés persistées doivent être sérialisables en JSON.
  • Je n'y place pas d'entités Entity Framework suivies, de références cycliques ou d'objets techniques.
  • Je ne persiste que l'état qui évite réellement une ressaisie utilisateur.
  • Cette fonctionnalité améliore la reprise d'une session, mais elle ne remplace pas l'enregistrement définitif en base de données.

Pour un brouillon de commande, je vais donc conserver les champs saisis et les lignes ajoutées. Lorsque l'utilisateur valide la commande, je l'enregistre normalement dans ma base : la persistance de circuit n'est pas mon stockage métier.


2. Activer le rendu interactif serveur et dimensionner le stockage mémoire

La persistance de circuit est disponible lorsque j'enregistre les composants interactifs serveur. Pour une application mono-instance, Blazor utilise par défaut un stockage mémoire. Microsoft indique que ce fournisseur conserve par défaut jusqu'à 1 000 circuits persistés pendant deux heures.

Dans une application métier, je préfère rendre cette politique explicite :

using Microsoft.AspNetCore.Components.Server;

var builder = WebApplication.CreateBuilder(args);

builder.Services.AddRazorComponents()
    .AddInteractiveServerComponents();

builder.Services.Configure<CircuitOptions>(options =>
{
    // Je borne volontairement la mémoire consommée par les brouillons en pause.
    options.PersistedCircuitInMemoryMaxRetained = 500;
    options.PersistedCircuitInMemoryRetentionPeriod = TimeSpan.FromMinutes(30);
});

var app = builder.Build();

app.MapRazorComponents<App>()
    .AddInteractiveServerRenderMode();

app.Run();

Le nombre et la durée à retenir dépendent de mon usage. Pour un extranet dans lequel une saisie prend dix minutes, trente minutes peuvent suffire. Pour un outil terrain utilisé sur mobile, je peux prévoir une durée supérieure, mais je dois la mesurer : persister trop de formulaires volumineux revient à déplacer le problème mémoire au lieu de le résoudre.


3. Premier exemple : conserver un formulaire de commande

Je pars d'une page permettant de préparer une commande avant validation. Les classes que je persiste sont volontairement de simples DTO sérialisables :

public sealed class CommandeBrouillon
{
    public Guid? ClientId { get; set; }
    public string ReferenceClient { get; set; } = string.Empty;
    public string Commentaire { get; set; } = string.Empty;
    public List<LigneCommandeBrouillon> Lignes { get; set; } = [];
}

public sealed class LigneCommandeBrouillon
{
    public Guid Id { get; set; } = Guid.NewGuid();
    public string CodeProduit { get; set; } = string.Empty;
    public int Quantite { get; set; } = 1;
}

Dans le composant, j'indique simplement à Blazor les propriétés dont je souhaite restaurer la valeur avec [PersistentState] :

@page "/commandes/nouvelle"
@rendermode InteractiveServer

<h1>Nouvelle commande</h1>

<EditForm Model="Draft" OnValidSubmit="EnregistrerAsync" FormName="nouvelle-commande">
    <DataAnnotationsValidator />

    <div class="mb-3">
        <label class="form-label">Référence client</label>
        <InputText class="form-control" @bind-Value="Draft.ReferenceClient" />
    </div>

    <div class="mb-3">
        <label class="form-label">Commentaire</label>
        <InputTextArea class="form-control" @bind-Value="Draft.Commentaire" />
    </div>

    <table class="table">
        <thead>
            <tr>
                <th>Produit</th>
                <th>Quantité</th>
            </tr>
        </thead>
        <tbody>
            @foreach (var ligne in Draft.Lignes)
            {
                <tr @key="ligne.Id">
                    <td>
                        <InputText class="form-control" @bind-Value="ligne.CodeProduit" />
                    </td>
                    <td>
                        <InputNumber class="form-control" @bind-Value="ligne.Quantite" />
                    </td>
                </tr>
            }
        </tbody>
    </table>

    <button type="button" class="btn btn-outline-secondary" @onclick="AjouterLigne">
        Ajouter une ligne
    </button>
    <button type="submit" class="btn btn-primary ms-2">
        Valider la commande
    </button>
</EditForm>

@code {
    [PersistentState]
    public CommandeBrouillon Draft { get; set; } = new();

    [PersistentState]
    public int CurrentStep { get; set; } = 1;

    protected override void OnInitialized()
    {
        if (Draft.Lignes.Count == 0)
        {
            Draft.Lignes.Add(new LigneCommandeBrouillon());
        }
    }

    private void AjouterLigne()
        => Draft.Lignes.Add(new LigneCommandeBrouillon());

    private async Task EnregistrerAsync()
    {
        // Ici seulement, j'enregistre définitivement la commande en base.
        await Task.CompletedTask;
    }
}

Deux détails comptent dans cet exemple :

  • La propriété marquée [PersistentState] est public, comme attendu par le mécanisme déclaratif de Blazor.
  • J'utilise @key="ligne.Id" dans la boucle : lorsque Blazor reconstruit la liste, chaque ligne conserve une identité stable et je limite les ambiguïtés de rendu.

Si le circuit est mis en pause puis repris, Draft et CurrentStep retrouvent leurs valeurs. En revanche, si l'utilisateur presse F5, je ne dois pas attendre de ce mécanisme qu'il restaure le brouillon : pour cette exigence, il me faut un véritable stockage de brouillon, par exemple en base de données ou via une API.


4. Deuxième exemple : conserver un service Scoped partagé entre plusieurs pages

Dans mes applications, une saisie métier n'est pas toujours contenue dans un seul composant. Un parcours peut comporter plusieurs écrans : sélection du client, ajout de produits, adresse de livraison et confirmation. Dans ce cas, je préfère porter l'état dans un service Scoped attaché au circuit.

using Microsoft.AspNetCore.Components;

public sealed class CommandeDraftState
{
    [PersistentState]
    public CommandeBrouillon Draft { get; set; } = new();

    [PersistentState]
    public int EtapeCourante { get; set; } = 1;

    public void NouvelleLigne()
        => Draft.Lignes.Add(new LigneCommandeBrouillon());
}

J'enregistre ensuite le service et je demande à Blazor de persister ses propriétés pour le mode interactif serveur :

using Microsoft.AspNetCore.Components.Web;

builder.Services.AddScoped<CommandeDraftState>();

builder.Services.AddRazorComponents()
    .AddInteractiveServerComponents()
    .RegisterPersistentService<CommandeDraftState>(RenderMode.InteractiveServer);

Le composant devient plus léger :

@page "/commandes/produits"
@rendermode InteractiveServer
@inject CommandeDraftState State

<h1>Produits de la commande</h1>

@foreach (var ligne in State.Draft.Lignes)
{
    <div @key="ligne.Id" class="row mb-2">
        <div class="col">
            <InputText class="form-control" @bind-Value="ligne.CodeProduit" />
        </div>
        <div class="col">
            <InputNumber class="form-control" @bind-Value="ligne.Quantite" />
        </div>
    </div>
}

<button class="btn btn-outline-secondary" @onclick="State.NouvelleLigne">
    Ajouter une ligne
</button>

J'utilise cette variante lorsque le brouillon doit survivre à la navigation entre plusieurs composants d'un même parcours. Je ne cumule pas inutilement les deux stratégies : soit le composant possède l'état, soit le service le centralise.


5. Mettre en pause un circuit quand l'onglet devient invisible

.NET 10 expose deux fonctions JavaScript : Blazor.pauseCircuit() et Blazor.resumeCircuit(). Elles permettent de décider qu'un circuit n'a plus besoin de consommer des ressources serveur tant que l'utilisateur n'affiche pas l'application.

Dans un extranet consulté depuis des mobiles, je peux installer une politique simple fondée sur la visibilité de l'onglet :

<script>
    window.addEventListener('visibilitychange', () => {
        if (document.visibilityState === 'hidden') {
            Blazor.pauseCircuit();
        } else if (document.visibilityState === 'visible') {
            Blazor.resumeCircuit();
        }
    });
</script>

Lorsque l'onglet passe en arrière-plan, Blazor sérialise les propriétés que j'ai sélectionnées, stocke les informations nécessaires côté navigateur et évacue le circuit serveur. À la reprise, un nouveau circuit est établi et l'état persistant est réhydraté.

Je n'applique toutefois pas ce comportement aveuglément. Si une page pilote un téléchargement, un paiement ou une opération critique en cours, je préfère contrôler précisément le moment de la pause. Le gain de ressources ne doit jamais conduire à interrompre une action métier que l'utilisateur pense avoir déclenchée.


6. Une instance ou plusieurs : choisir le bon stockage

Pour une seule instance applicative, le cache mémoire fourni par défaut est adapté à de nombreux scénarios. Il est simple et ne nécessite aucune infrastructure supplémentaire.

Dès que je déploie plusieurs instances derrière un répartiteur de charge, ou que je souhaite survivre au redémarrage d'un processus, le stockage en mémoire n'est plus suffisant. Un circuit repris peut arriver sur une autre instance, qui ne connaît pas le snapshot mémorisé sur la première.

Dans ce cas, .NET 10 permet d'utiliser HybridCache avec un stockage distribué, par exemple Redis :

builder.Services.AddHybridCache()
    .AddRedis(builder.Configuration.GetConnectionString("Redis")!);

builder.Services.AddRazorComponents()
    .AddInteractiveServerComponents();

Avec cette configuration, je peux concevoir une politique de déploiement plus robuste : les circuits mis en pause disposent d'un état accessible aux différentes instances. Je conserve malgré tout une règle stricte : une commande validée doit être enregistrée dans la base métier, jamais seulement dans le cache de reprise de circuit.


7. Comment je teste cette fonctionnalité

Je teste la persistance de circuit avec des scénarios orientés utilisateur, pas uniquement en lisant le code :

  1. J'ouvre la page de création de commande en mode InteractiveServer.
  2. Je renseigne une référence, un commentaire et plusieurs lignes de produits.
  3. Je déclenche Blazor.pauseCircuit() depuis la console du navigateur, ou je masque l'onglet si j'ai branché l'événement visibilitychange.
  4. Je reprends le circuit avec Blazor.resumeCircuit().
  5. Je vérifie que les champs et lignes réapparaissent sans nouvel appel métier inutile.
  6. Je recharge ensuite complètement la page et je vérifie que le comportement est bien celui prévu : l'état de circuit n'est pas un brouillon permanent.

En environnement distribué, j'ajoute un test avec deux instances et Redis. Je mets un circuit en pause, je force la reprise sur une autre instance puis je contrôle que les données sélectionnées sont restaurées.


8. Les règles que je retiens en production

Cette fonctionnalité est puissante à condition de rester discipliné. Voici les règles que j'applique :

Règle Pourquoi
Je persiste uniquement les saisies ou choix coûteux à reproduire. Un snapshot trop gros consomme du stockage et ralentit la reprise.
Je persiste des DTO simples, jamais mes entités ORM suivies. La sérialisation doit rester prévisible et sans références cycliques.
Je mets @key sur les collections éditables. Les lignes restaurées gardent une identité stable dans le rendu.
Je considère le cache de circuit comme temporaire. Une donnée métier validée doit survivre à une expiration ou à une panne.
Je revalide l'autorisation et les données critiques lors de l'enregistrement final. Une reprise de session ne dispense jamais des contrôles serveur.
Je mesure le nombre de circuits et la taille des états persistés. Les limites mémoire ou Redis doivent être pilotées par l'usage réel.

Conclusion

Avec .NET 10, je peux traiter une faiblesse historique des interfaces Blazor Server : la perte d'une saisie utile lorsque le circuit ne peut pas être reconnecté dans son état original. Le mécanisme est volontairement ciblé : je choisis les propriétés avec [PersistentState], je centralise éventuellement le brouillon dans un service Scoped, puis je décide quand suspendre et reprendre le circuit.

Pour mes applications métier, le cas d'usage est immédiat : formulaires longs, paniers, assistants de configuration et parcours multi-étapes. La mise en oeuvre est courte, mais le choix des données persistées reste un sujet d'architecture. Je dois conserver suffisamment d'état pour éviter la frustration utilisateur, sans confondre une reprise temporaire de circuit avec un enregistrement métier fiable.

Sources