Accueil › Construire le web › Chapitre 16

Blazor : l'interface web en C#

Blazor permet d'écrire l'interface d'une application web en C# au lieu de JavaScript. La difficulté n'est pas la syntaxe — elle est très proche de HTML — mais les modes de rendu : le même composant peut s'exécuter sur le serveur, dans le navigateur, ou pas du tout de façon interactive. Ce chapitre y consacre l'essentiel de son temps, parce que c'est là que tout le monde se perd.

Un composant, c'est un fichier

Compteur.razor
<h3>@Titre</h3>
<p>Valeur actuelle : <b>@_valeur</b></p>

<button class="btn" @onclick="Incrementer" disabled="@(_valeur >= Maximum)">
    +1
</button>

@if (_valeur >= Maximum)
{
    <p class="alerte">Maximum atteint</p>
}

<ul>
@foreach (var h in _historique)
{
    <li @key="h.Id">@h.Date : @h.Valeur</li>
}
</ul>

@code {
    [Parameter] public string Titre { get; set; } = "Compteur";
    [Parameter] public int Maximum { get; set; } = 10;
    [Parameter] public EventCallback<int> OnChangement { get; set; }

    private int _valeur;
    private readonly List<Entree> _historique = [];

    private async Task Incrementer()
    {
        _valeur++;
        _historique.Add(new Entree(Guid.NewGuid(), DateTime.Now, _valeur));
        await OnChangement.InvokeAsync(_valeur);   // prévient le parent
    }

    private record Entree(Guid Id, DateTime Date, int Valeur);
}
Utilisation depuis un parent
<Compteur Titre="Paniers" Maximum="5" OnChangement="Enregistrer" />

@code {
    private void Enregistrer(int valeur) => Console.WriteLine($"nouvelle valeur : {valeur}");
}
L'image

Un composant est une brique Lego. Il a une forme (son HTML), des plots d'entrée ([Parameter]), des plots de sortie (EventCallback), et son état interne caché. On les emboîte pour construire une page. Le parent ne fouille pas dans l'enfant : il le paramètre et écoute ses événements.

Le sens des flux : paramètres vers le bas, événements vers le haut

Un enfant ne modifie jamais l'état de son parent directement. Il signale (OnChangement.InvokeAsync(...)), le parent décide. Et @key dans une boucle est loin d'être décoratif : sans lui, Blazor peut réutiliser le mauvais élément du DOM lors d'un réordonnancement (champs de saisie qui échangent leurs valeurs, animations qui sautent).

Les modes de rendu : le cœur du sujet

Depuis .NET 8, une Blazor Web App peut mélanger quatre modes dans la même application, page par page, voire composant par composant.

ModeLe code C# tourne…Interactif ?AvantagesLimites
Static SSR
(par défaut)
sur le serveur, une foisNonLe plus rapide, référençable, aucune connexion à maintenirPas de @onclick : chaque action passe par un formulaire ou un lien
InteractiveServersur le serveur, en continuOuiAccès direct à la base et aux secrets, téléchargement minuscule, débogage simpleUne connexion WebSocket par utilisateur ; latence à chaque interaction ; état perdu si la connexion tombe
InteractiveWebAssemblydans le navigateurOuiAucune latence, fonctionne hors ligne, décharge le serveurTéléchargement initial (plusieurs Mo) ; aucun accès direct à la base ; aucun secret
InteractiveAutoserveur d'abord, puis navigateurOuiDémarrage immédiat, puis autonomieLe code doit fonctionner dans les deux contextes ; complexité accrue
Static SSR Navigateur Serveur → HTML une requête, une page. Fin. InteractiveServer Navigateur DOM seulement clic diff DOM Serveur état + composants WebSocket permanent (« circuit »). Le C# a accès à la base et aux secrets. InteractiveWebAssembly Navigateur runtime .NET + tes DLL état + composants API HTTP Le serveur devient une API comme pour un front React : sécurité côté API.
Déclarer le mode
@* Sur une page ou un composant *@
@rendermode InteractiveServer
@rendermode InteractiveWebAssembly
@rendermode InteractiveAuto
@rendermode @(new InteractiveServerRenderMode(prerender: false))   @* sans prérendu *@

@* Sur une instance précise, depuis le parent *@
<Compteur @rendermode="InteractiveServer" />
Program.cs — activer les modes utilisés
builder.Services.AddRazorComponents()
    .AddInteractiveServerComponents()
    .AddInteractiveWebAssemblyComponents();

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

Lequel choisir ?

La page a-t-elle besoin d'interactivité C# ? non Static SSR le plus rapide, le défaut oui Besoin du hors-ligne, ou serveur à décharger, ou latence critique ? non InteractiveServer le plus simple : accès direct WebAssembly ou Auto si tu veux aussi un premier affichage instantané
Conseil pragmatique : commence tout en Static SSR et n'ajoute l'interactivité que sur les composants qui en ont besoin. Une application intranet est très bien en InteractiveServer.
Ce que WebAssembly implique vraiment

Prérendu : le piège de la double exécution

Par défaut, un composant interactif est d'abord rendu sur le serveur (HTML immédiat), puis « branché » côté client. Conséquence : OnInitializedAsync s'exécute deux fois.

Double chargement
protected override async Task OnInitializedAsync()
{
    // Exécuté au prérendu ET au démarrage interactif
    Films = await Service.ChargerAsync();   // 2 appels
}
Un seul chargement .NET 10
[PersistentState]                       // l'état traverse la frontière
public List<Film>? Films { get; set; }

protected override async Task OnInitializedAsync()
    => Films ??= await Service.ChargerAsync();  // ??= : une seule fois

L'autre option est de désactiver le prérendu (prerender: false) : le premier affichage montre alors un indicateur de chargement, ce qui est parfois exactement ce que tu veux pour un tableau de bord derrière authentification.

Le cycle de vie

MéthodeQuandÀ y mettre
SetParametersAsyncAvant tout, à chaque changementPresque jamais : cas très avancés
OnInitialized(Async)Une fois par instanceChargement initial, abonnements
OnParametersSet(Async)À chaque nouveau jeu de paramètresRecharger quand un paramètre change (ex. l'identifiant dans l'URL)
OnAfterRender(Async)Après chaque rendu, côté client uniquementInterop JavaScript, focus, graphiques
ShouldRenderAvant un nouveau renduOptimisation rare et mesurée
Dispose(Async)Destruction du composantSe désabonner, annuler les timers
@implements IAsyncDisposable
@inject IJSRuntime JS

@code {
    private IJSObjectReference? _module;
    private CancellationTokenSource _cts = new();

    protected override async Task OnAfterRenderAsync(bool premierRendu)
    {
        if (!premierRendu) return;            // ← indispensable : sinon exécuté à chaque rendu
        _module = await JS.InvokeAsync<IJSObjectReference>("import", "./js/graphique.js");
        await _module.InvokeVoidAsync("dessiner", _donnees);
    }

    public async ValueTask DisposeAsync()
    {
        _cts.Cancel();
        if (_module is not null) await _module.DisposeAsync();   // fuite mémoire sinon
    }
}
Les trois erreurs classiques du cycle de vie
  1. Appeler du JavaScript dans OnInitializedAsync : au prérendu, il n'y a pas encore de DOM ni de navigateur → InvalidOperationException. L'interop va dans OnAfterRenderAsync.
  2. Oublier if (!premierRendu) return; : ton graphique se redessine à chaque frappe au clavier.
  3. S'abonner sans se désabonner : le composant détruit reste référencé par l'événement, l'interface fuit et se met à jour dans le vide.

Quand l'interface ne se met pas à jour

Blazor redessine automatiquement après un gestionnaire d'événement. En revanche, si l'état change ailleurs (timer, message SignalR, événement d'un service), il faut le lui dire.

@implements IDisposable
@inject NotificationService Notifs

@code {
    private void Recevoir(string message)
    {
        _messages.Add(message);
        StateHasChanged();          // ← sans cela, rien ne bouge à l'écran
    }

    // Si l'événement vient d'un autre thread (timer, SignalR), il faut revenir
    // sur le fil de rendu du composant :
    private void RecevoirDepuisAilleurs(string m) =>
        InvokeAsync(() => { _messages.Add(m); StateHasChanged(); });

    protected override void OnInitialized() => Notifs.Recu += Recevoir;
    public void Dispose() => Notifs.Recu -= Recevoir;      // ← toujours
}
Jamais async void dans un gestionnaire Blazor
<button @onclick="Mauvais">…</button>   @* async void : exception avalée, aucun rendu final *@
<button @onclick="Bon">…</button>

@code {
    private async void Mauvais() { await Charger(); }        // ❌
    private async Task Bon()     { await Charger(); }        // ✅ Blazor attend et redessine
}

Formulaires et validation

@rendermode InteractiveServer

<EditForm Model="_modele" OnValidSubmit="Envoyer" FormName="inscription">
    <DataAnnotationsValidator />
    <ValidationSummary />

    <label>Nom
        <InputText @bind-Value="_modele.Nom" class="champ" />
        <ValidationMessage For="() => _modele.Nom" />
    </label>

    <label>Email       <InputText @bind-Value="_modele.Email" type="email" /></label>
    <label>Naissance   <InputDate @bind-Value="_modele.Naissance" /></label>
    <label>Catégorie   <InputSelect @bind-Value="_modele.Categorie">
        @foreach (var c in Enum.GetValues<Categorie>())
        {
            <option value="@c">@c</option>
        }
    </InputSelect></label>
    <label><InputCheckbox @bind-Value="_modele.Conditions" /> J'accepte</label>

    <button type="submit" disabled="@_envoi">@(_envoi ? "Envoi…" : "S'inscrire")</button>
</EditForm>

@code {
    [SupplyParameterFromForm] private Inscription _modele { get; set; } = new();
    private bool _envoi;

    private async Task Envoyer()
    {
        _envoi = true;
        try { await Service.InscrireAsync(_modele); }
        finally { _envoi = false; }
    }

    public class Inscription
    {
        [Required(ErrorMessage = "Le nom est obligatoire")]
        [StringLength(80, MinimumLength = 2)]
        public string Nom { get; set; } = "";

        [Required, EmailAddress] public string Email { get; set; } = "";
        public DateOnly Naissance { get; set; }
        public Categorie Categorie { get; set; }
        [Range(typeof(bool), "true", "true", ErrorMessage = "Acceptation requise")]
        public bool Conditions { get; set; }
    }
}
Les formulaires fonctionnent aussi en Static SSR

Avec FormName et [SupplyParameterFromForm], un EditForm se soumet comme un formulaire HTML classique — sans interactivité, sans WebSocket. ASP.NET Core 11 y ajoute la validation côté client et la persistance de TempData, ce qui met le rendu statique quasiment à parité avec MVC. Et .NET 11 introduit la validation asynchrone (vérifier en base qu'un email est libre pendant la soumission).

Gérer l'état, selon le mode

BesoinSolutionAttention
État d'un composantUn champ privéPerdu à la destruction du composant
Partage entre composants d'une pageCascadingValue ou un service scopedEn Server, « scoped » = durée du circuit (l'onglet), pas de la requête !
Traverser le prérendu[PersistentState].NET 10+ ; sérialisable uniquement
Survivre à un rafraîchissement (F5)localStorage / sessionStorage via interopInaccessible pendant le prérendu
Données serveurLa base, via un service injecté ou une APILa seule source de vérité fiable
« Scoped » ne veut pas dire la même chose en Blazor Server

Dans une API, un service scoped vit le temps d'une requête HTTP. En Blazor Server, il vit le temps du circuit — c'est-à-dire de l'onglet du navigateur, potentiellement des heures. Un DbContext injecté directement dans un composant devient donc un contexte de très longue durée, qui accumule des entités suivies. Injecte plutôt un IDbContextFactory<T> et crée un contexte par opération.

Composants et outils fournis

@* Longue liste : ne rend que ce qui est visible *@
<Virtualize Items="_produits" Context="p" ItemSize="42" OverscanCount="4" InitialIndex="500">
    <div class="ligne">@p.Nom — @p.Prix.ToString("C")</div>
</Virtualize>

@* Tableau avec tri et pagination *@
<QuickGrid Items="_requete" Pagination="_pagination" RowClass="@(p => p.Stock == 0 ? "rupture" : null)">
    <PropertyColumn Property="p => p.Nom" Sortable="true" />
    <PropertyColumn Property="p => p.Prix" Format="C" Align="Align.Right" />
    <TemplateColumn Title="Actions">
        <button @onclick="() => Modifier(context.Id)">Éditer</button>
    </TemplateColumn>
</QuickGrid>
<Paginator State="_pagination" />

@* Affichage conditionné aux droits *@
<AuthorizeView Roles="admin">
    <Authorized><button @onclick="Supprimer">Supprimer</button></Authorized>
    <NotAuthorized><p>Réservé aux administrateurs</p></NotAuthorized>
</AuthorizeView>

@* Rendu progressif : la page part tout de suite, les données arrivent ensuite *@
@attribute [StreamRendering]

.NET 11 ajoute ScrollToIndexAsync sur Virtualize, un composant Label accessible, EnvironmentBoundary pour n'afficher un bloc que dans certains environnements, et OnRowClick sur QuickGrid (chapitre 22).

@page "/produits"
@page "/produits/{Id:int}"                @* deux routes pour le même composant *@
@page "/catalogue/{*chemin}"              @* capture le reste du chemin *@

@inject NavigationManager Nav

@code {
    [Parameter] public int? Id { get; set; }
    [SupplyParameterFromQuery] public string? Recherche { get; set; }   // ?recherche=clavier

    private void Aller() => Nav.NavigateTo($"/produits/{42}");
    private void Remplacer() => Nav.NavigateTo("/accueil", replace: true);
    private void Forcer() => Nav.NavigateTo("/produits", forceLoad: true);   // rechargement complet

    // Recharger quand le paramètre de route change (le composant, lui, n'est pas recréé)
    protected override async Task OnParametersSetAsync() => await ChargerAsync(Id);
}

Interop JavaScript

// C# → JS
await JS.InvokeVoidAsync("localStorage.setItem", "theme", "sombre");
var largeur = await JS.InvokeAsync<int>("eval", "window.innerWidth");   // à éviter : préfère un module

// Module ES6 isolé (la bonne pratique)
var module = await JS.InvokeAsync<IJSObjectReference>("import", "./js/carte.js");
await module.InvokeVoidAsync("initialiser", "carte", 48.85, 2.35);

// JS → C# : passer une référence d'objet .NET
var reference = DotNetObjectReference.Create(this);
await module.InvokeVoidAsync("surClicCarte", reference);

[JSInvokable]
public void PointChoisi(double lat, double lon)
{
    _position = (lat, lon);
    StateHasChanged();
}
// ⚠️ reference.Dispose() dans DisposeAsync, sinon fuite mémoire côté .NET
Détails de production

Vérifie que c'est passé

🎯 Quiz — 5 questions

1. Tu ajoutes un @onclick sur une page d'une Blazor Web App et rien ne se passe. Pourquoi ?

C'est le malentendu nº 1 depuis .NET 8 : le mode par défaut est statique. Ajoute @rendermode InteractiveServer sur la page ou le composant.

2. Peux-tu injecter un DbContext dans un composant WebAssembly ?

Le navigateur n'a aucun accès réseau à ta base — et publier une chaîne de connexion dans du code téléchargé serait une faille majeure.

3. OnInitializedAsync charge tes données deux fois. Cause ?

Utilise [PersistentState] avec ??= (.NET 10), ou désactive le prérendu avec prerender: false.

4. Un timer met à jour une propriété, mais l'écran ne change pas. Que fais-tu ?

Blazor ne redessine automatiquement qu'après ses propres événements. Depuis un autre fil d'exécution, il faut revenir sur le contexte de rendu avec InvokeAsync.

5. En Blazor Server, un service scoped vit combien de temps ?

D'où l'usage d'IDbContextFactory plutôt qu'un DbContext injecté directement dans les composants.

Fiches de révision

Le mode par défaut ?
Static SSR : aucun @onclick ne fonctionne sans @rendermode.
Server vs WebAssembly ?
Server = C# sur le serveur via WebSocket, accès direct à la base. WASM = C# dans le navigateur, API obligatoire, aucun secret.
Où appeler du JavaScript ?
Dans OnAfterRenderAsync(premierRendu), jamais dans OnInitializedAsync.
Paramètres et événements ?
[Parameter] descend, EventCallback remonte. L'enfant ne décide pas.
Interface figée après un timer ?
InvokeAsync(StateHasChanged).
Pourquoi @key ?
Pour que Blazor réassocie correctement les éléments du DOM quand une liste change d'ordre.
À retenir