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
<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);
}
<Compteur Titre="Paniers" Maximum="5" OnChangement="Enregistrer" />
@code {
private void Enregistrer(int valeur) => Console.WriteLine($"nouvelle valeur : {valeur}");
}
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.
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.
| Mode | Le code C# tourne… | Interactif ? | Avantages | Limites |
|---|---|---|---|---|
| Static SSR (par défaut) | sur le serveur, une fois | Non | Le plus rapide, référençable, aucune connexion à maintenir | Pas de @onclick : chaque action passe par un formulaire ou un lien |
| InteractiveServer | sur le serveur, en continu | Oui | Accès direct à la base et aux secrets, téléchargement minuscule, débogage simple | Une connexion WebSocket par utilisateur ; latence à chaque interaction ; état perdu si la connexion tombe |
| InteractiveWebAssembly | dans le navigateur | Oui | Aucune latence, fonctionne hors ligne, décharge le serveur | Téléchargement initial (plusieurs Mo) ; aucun accès direct à la base ; aucun secret |
| InteractiveAuto | serveur d'abord, puis navigateur | Oui | Démarrage immédiat, puis autonomie | Le code doit fonctionner dans les deux contextes ; complexité accrue |
@* 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" />
builder.Services.AddRazorComponents()
.AddInteractiveServerComponents()
.AddInteractiveWebAssemblyComponents();
app.MapRazorComponents<App>()
.AddInteractiveServerRenderMode()
.AddInteractiveWebAssemblyRenderMode();
Lequel choisir ?
- Aucun secret ne peut vivre dans un composant WebAssembly : le code est téléchargé chez le client, donc lisible. Pas de chaîne de connexion, pas de clé d'API, pas de règle métier confidentielle.
- Pas d'accès direct à la base : il faut une API HTTP, et donc revalider les autorisations côté serveur. Une vérification faite dans le composant n'est qu'un confort d'affichage.
- Le projet est séparé (
MonApp.Client) : un composant WebAssembly ne peut pas référencer un service qui n'existe que côté serveur.
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.
protected override async Task OnInitializedAsync()
{
// Exécuté au prérendu ET au démarrage interactif
Films = await Service.ChargerAsync(); // 2 appels
}[PersistentState] // l'état traverse la frontière
public List<Film>? Films { get; set; }
protected override async Task OnInitializedAsync()
=> Films ??= await Service.ChargerAsync(); // ??= : une seule foisL'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éthode | Quand | À y mettre |
|---|---|---|
SetParametersAsync | Avant tout, à chaque changement | Presque jamais : cas très avancés |
OnInitialized(Async) | Une fois par instance | Chargement initial, abonnements |
OnParametersSet(Async) | À chaque nouveau jeu de paramètres | Recharger quand un paramètre change (ex. l'identifiant dans l'URL) |
OnAfterRender(Async) | Après chaque rendu, côté client uniquement | Interop JavaScript, focus, graphiques |
ShouldRender | Avant un nouveau rendu | Optimisation rare et mesurée |
Dispose(Async) | Destruction du composant | Se 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
}
}
- Appeler du JavaScript dans
OnInitializedAsync: au prérendu, il n'y a pas encore de DOM ni de navigateur →InvalidOperationException. L'interop va dansOnAfterRenderAsync. - Oublier
if (!premierRendu) return;: ton graphique se redessine à chaque frappe au clavier. - 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
}
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; }
}
}
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
| Besoin | Solution | Attention |
|---|---|---|
| État d'un composant | Un champ privé | Perdu à la destruction du composant |
| Partage entre composants d'une page | CascadingValue ou un service scoped | En 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 interop | Inaccessible pendant le prérendu |
| Données serveur | La base, via un service injecté ou une API | La seule source de vérité fiable |
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).
Routage et navigation
@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
- Taille du téléchargement WebAssembly : active le trimming, la compression Brotli et la précompilation. Vérifie le poids réel dans l'onglet réseau du navigateur — plusieurs mégaoctets au premier chargement sont normaux, mais ils sont ensuite mis en cache.
- Circuits (Server) : chaque onglet consomme de la mémoire serveur. Règle
DisconnectedCircuitMaxRetainedetDisconnectedCircuitRetentionPeriod. ASP.NET Core 11 ajoute la pause de circuit déclenchée par le serveur, qui libère les ressources d'un onglet inactif et le reprend ensuite. - Répartition de charge : un circuit est lié à une instance de serveur → sessions persistantes obligatoires (sticky sessions), sinon la reconnexion échoue.
- CSS isolé :
Composant.razor.cssne s'applique qu'à ce composant.::deeppermet d'atteindre les enfants. - Erreurs non gérées : en Server, elles ferment le circuit (barre d'erreur en bas de page). Encadre
ce qui peut échouer, et utilise
ErrorBoundarypour isoler une zone de l'interface. - Blazor n'est pas la seule option : pour un site public très fréquenté, du Static SSR (ou une API + un front JavaScript) reste souvent plus adapté. Blazor Server brille en intranet et en applications métier ; WebAssembly dans les applications riches à état complexe.
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 ?
@rendermode InteractiveServer sur la page ou le composant.2. Peux-tu injecter un DbContext dans un composant WebAssembly ?
3. OnInitializedAsync charge tes données deux fois. Cause ?
[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 ?
InvokeAsync.5. En Blazor Server, un service scoped vit combien de temps ?
IDbContextFactory plutôt qu'un DbContext
injecté directement dans les composants.Fiches de révision
@onclick ne fonctionne sans @rendermode.OnAfterRenderAsync(premierRendu), jamais dans OnInitializedAsync.[Parameter] descend, EventCallback remonte. L'enfant ne décide pas.InvokeAsync(StateHasChanged).@key ?- Les modes de rendu d'abord : statique par défaut, interactivité ajoutée là où elle est nécessaire.
- WebAssembly = code public : aucun secret, aucune décision de sécurité côté client.
- Prérendu = double exécution :
[PersistentState]ouprerender: false. - Interop JS dans
OnAfterRenderAsync, avecif (!premierRendu) return;. - Désabonne-toi dans
Dispose;async Task, jamaisasync void. - En Server, « scoped » = le circuit :
IDbContextFactoryplutôt qu'unDbContext.