Blazor Auto: o componente troca de lado e o estado fica para trás
Entenda por que um componente InteractiveAuto busca dados duas vezes, perde o que estava num serviço e esquece quem está logado, e como desenhar o estado para os dois lados.
A tela de pedidos abre com a lista certa e o nome do cliente no topo. Meio segundo depois a lista pisca, some e volta. Na semana seguinte, depois de um refresh, o mesmo componente mostra "entre na sua conta" para quem acabou de entrar. Ninguém mexeu no login. O componente está em InteractiveAuto e, a cada visita, pode estar rodando num lugar diferente.
A resposta curta: no modo Auto, o mesmo componente roda no servidor (pré-renderização e circuito SignalR) e depois no navegador (WebAssembly), e nada do que estava na memória de um lado chega sozinho ao outro. Você precisa escolher onde cada dado nasce, atravessar a fronteira de propósito (com PersistentComponentState ou com uma API) e testar o primeiro carregamento, o refresh e a segunda visita.
O que o modo Auto faz com o seu componente
Num Blazor Web App, um componente marcado com @rendermode InteractiveAuto passa por até três vidas. Primeiro, por padrão, ele é pré-renderizado no servidor: o HTML sai pronto na resposta HTTP, e a pessoa vê a página antes de qualquer interatividade. Depois, se o pacote WebAssembly ainda não está no cache do navegador, ele vira interativo pelo servidor, num circuito SignalR. Nas visitas seguintes, com o pacote já baixado, ele vira interativo direto no navegador.
Cada uma dessas vidas cria uma instância nova do componente. O OnInitializedAsync roda de novo. Os serviços injetados vêm de containers diferentes: no servidor, um serviço scoped vive enquanto dura o circuito; no WebAssembly, vive enquanto a aba estiver aberta. Um campo preenchido na pré-renderização não existe na instância interativa. É por isso que a lista pisca: a primeira instância buscou os pedidos, desenhou o HTML e morreu; a segunda começou do zero e buscou de novo.
Por que parece bug de login
Autenticação é só mais um estado. No servidor, o componente enxerga o usuário porque o cookie chegou na requisição. No navegador, o código WebAssembly não lê cookie HttpOnly (e não deve ler). Se ninguém passou o estado de autenticação para o lado cliente, o componente pergunta "quem é você?" e recebe um usuário anônimo. A sessão está ótima; o componente é que está do outro lado da fronteira. O desenho específico para isso está em como o componente WebAssembly fica sabendo quem está logado. Aqui o foco é o estado em geral: dados, serviços e o usuário como um caso particular.
Como desenhar o estado para os dois lados
O exemplo que atravessa o texto: uma loja com a página /pedidos, que lista os 20 últimos pedidos do cliente logado. O componente fica no projeto Loja.Client, porque precisa rodar no navegador também. O banco só existe no servidor.
- Defina um contrato que os dois lados implementam. O componente depende de
IPedidoService, não doDbContextnem doHttpClient. - Implemente o lado servidor indo direto ao repositório, descobrindo o usuário pelo
AuthenticationStateProvider. - Implemente o lado cliente chamando uma API do próprio servidor, que exige autenticação.
- Registre cada implementação no seu
Program.cs. O servidor registra a dele; o cliente registra a dele. O componente não sabe qual recebeu. - Persista o resultado da pré-renderização com
PersistentComponentState, para a instância interativa não buscar de novo. - Teste os três caminhos: primeira visita com cache limpo, refresh com F5 e navegação depois que o WebAssembly já carregou.
O contrato e as duas implementações
// Loja.Client/Pedidos/IPedidoService.cs
public record PedidoResumo(int Id, decimal Total, DateTime CriadoEm);
public interface IPedidoService
{
Task<List<PedidoResumo>> ListarAsync();
}
// Loja.Client/Pedidos/PedidoServiceCliente.cs
public sealed class PedidoServiceCliente(HttpClient http) : IPedidoService
{
public async Task<List<PedidoResumo>> ListarAsync() =>
await http.GetFromJsonAsync<List<PedidoResumo>>("api/pedidos") ?? [];
}
// Loja/Pedidos/PedidoServiceServidor.cs
public sealed class PedidoServiceServidor(
PedidoRepositorio repo,
AuthenticationStateProvider auth) : IPedidoService
{
public async Task<List<PedidoResumo>> ListarAsync()
{
var estado = await auth.GetAuthenticationStateAsync();
var clienteId = estado.User.FindFirstValue(ClaimTypes.NameIdentifier)
?? throw new UnauthorizedAccessException("Usuário não autenticado.");
return await repo.ListarDoClienteAsync(clienteId);
}
}
A API que o cliente chama não usa o AuthenticationStateProvider, porque fora do pipeline do Blazor quem diz quem é o usuário é o próprio ClaimsPrincipal da requisição:
// Loja/Program.cs
builder.Services.AddScoped<PedidoRepositorio>();
builder.Services.AddScoped<IPedidoService, PedidoServiceServidor>();
app.MapGet("/api/pedidos", async (ClaimsPrincipal user, PedidoRepositorio repo) =>
{
var id = user.FindFirstValue(ClaimTypes.NameIdentifier);
return id is null
? Results.Unauthorized()
: Results.Ok(await repo.ListarDoClienteAsync(id));
}).RequireAuthorization();
// Loja.Client/Program.cs
builder.Services.AddScoped(sp =>
new HttpClient { BaseAddress = new Uri(builder.HostEnvironment.BaseAddress) });
builder.Services.AddScoped<IPedidoService, PedidoServiceCliente>();
Repare que a regra "cada cliente só vê os próprios pedidos" mora no repositório, no servidor. O lado cliente não filtra nada; ele pede e recebe o que o servidor autorizou. Se a regra estivesse no componente, bastaria abrir o DevTools e chamar a API com outro parâmetro.
Sem buscar duas vezes: PersistentComponentState
Na pré-renderização, o componente registra uma função que serializa os dados no próprio HTML da página. Quando a instância interativa nasce, ela tenta pegar esses dados antes de chamar o serviço:
@page "/pedidos"
@rendermode InteractiveAuto
@implements IDisposable
@inject IPedidoService Pedidos
@inject PersistentComponentState Estado
@if (pedidos is null)
{
<p>Carregando pedidos...</p>
}
else
{
<ul>
@foreach (var p in pedidos)
{
<li>#@p.Id: @p.Total.ToString("C") em @p.CriadoEm.ToString("d")</li>
}
</ul>
}
@code {
private List<PedidoResumo>? pedidos;
private PersistingComponentStateSubscription assinatura;
protected override async Task OnInitializedAsync()
{
assinatura = Estado.RegisterOnPersisting(Persistir);
if (!Estado.TryTakeFromJson<List<PedidoResumo>>("pedidos", out var salvos))
{
salvos = await Pedidos.ListarAsync();
}
pedidos = salvos;
}
private Task Persistir()
{
Estado.PersistAsJson("pedidos", pedidos);
return Task.CompletedTask;
}
public void Dispose() => assinatura.Dispose();
}
Com isso, a primeira visita faz uma consulta ao banco, não duas. Versões mais novas do .NET oferecem uma forma declarativa (um atributo na propriedade) para o mesmo mecanismo; se a sua versão tem, use, mas o modelo mental é o mesmo: o dado viaja dentro do HTML, então ele precisa ser pequeno e não pode conter segredo. Tudo que você persiste fica visível no código-fonte da página.
Onde cada tipo de estado deve morar
- Dado de negócio (pedidos, saldo, estoque): nasce no servidor. O cliente pede por API autorizada.
- Quem está logado: nasce no cookie do servidor. O cliente recebe uma cópia das informações de que a interface precisa, nunca o cookie.
- Estado de tela (aba aberta, filtro digitado): pode viver no componente ou na URL. Na URL ele sobrevive ao refresh e à troca de lado de graça.
- Estado compartilhado entre componentes da mesma página: um serviço scoped funciona, desde que você aceite que ele recomeça vazio quando o componente muda de modo.
Erros comuns
- Injetar o
DbContextnum componente do projeto Client. Compila em um lado e quebra no outro; quando o WebAssembly assume, não há banco nem connection string no navegador. - Guardar o carrinho num serviço scoped do servidor e esperar que ele esteja lá depois. Quando a página passa a rodar no navegador, o circuito acabou e o carrinho foi junto.
- Persistir segredo com
PersistentComponentState. O valor vai serializado no HTML; um token ali é um token publicado. - Testar só com o circuito quente do debug. O desenvolvedor navega de uma página para outra sem nunca dar refresh e não vê a troca de modo que o usuário vê.
- Duplicar a regra de autorização no componente. O
ifno cliente esconde o botão, mas a API semRequireAuthorizationcontinua entregando o dado. - Desligar a pré-renderização para "resolver" a busca dupla. Some o pisca, mas a página fica em branco até a interatividade chegar e perde o HTML que os buscadores leem.
Checklist
- O componente depende de uma interface, com uma implementação registrada em cada
Program.cs. - A API usada pelo lado cliente exige autenticação e filtra pelo usuário no servidor.
- Na primeira visita, os logs mostram uma consulta ao banco por carregamento de página, não duas.
- Nada persistido no HTML é senha, token ou dado de outro usuário.
- Refresh com F5 na página interativa ainda mostra o usuário certo.
- Você testou com o cache do navegador limpo e com o WebAssembly já baixado.
- Estado que precisa sobreviver ao refresh está na URL ou no servidor, não num serviço em memória.
Abra agora a página mais usada do seu Blazor Auto, limpe o cache e conte as requisições ao banco no primeiro carregamento: o mesmo dado está sendo buscado uma vez ou duas?
Referência: https://www.telerik.com/blogs/fetching-sharing-data-between-components-blazor-auto-render-mode