Blazor: componentes, parâmetros, eventos e ciclo de vida

Blazor: componentes, parâmetros, eventos e ciclo de vida

O carrinho da Livraria vira um componente Blazor escrito só em C#: parâmetros que descem, EventCallback que sobe, @bind em componentes próprios, ciclo de vida com carregamento e cancelamento, @key em listas, fragmentos de conteúdo, EditForm com os mesmos atributos das páginas e CSS isolado.
Linguagem C#

• • 22 min de leitura

As Razor Pages montam páginas inteiras. Neste artigo a Livraria ganha interfaces que reagem a cada clique sem recarregar a página, e elas continuam escritas em C#. O Blazor organiza essas interfaces em componentes, e começamos pelo carrinho de compras.

Do formulário ao componente

No artigo anterior, cada interação era uma ida e volta completa: o formulário é enviado, o servidor monta a página inteira de novo e o navegador a redesenha. Para cotar um frete, isso funciona bem. Para um carrinho, em que o cliente aumenta a quantidade de um livro, remove outro e vê subtotal, frete e total mudarem a cada gesto, uma página nova por clique é lenta e desajeitada.

Até pouco tempo, essa interatividade exigia JavaScript e um framework como React, Vue ou Angular, com uma segunda linguagem, um segundo conjunto de ferramentas e os modelos de dados duplicados. O Blazor oferece o mesmo modelo de componentes desses frameworks, mas em C#, com os mesmos tipos, a mesma DI e as mesmas bibliotecas do resto da Livraria.

O Blazor nasceu em 2017 como um experimento pessoal de Steve Sanderson, da equipe do ASP.NET, apresentado no NDC Oslo: rodar .NET no navegador via WebAssembly. Em 2018 virou um projeto experimental oficial da Microsoft. Chegou ao .NET Core 3.0 (2019) primeiro no modo Blazor Server e, em 2020, no modo WebAssembly. No .NET 8 os dois foram unificados num único modelo, com modos de renderização escolhidos por componente, que é o assunto do próximo artigo. Aqui vamos nos concentrar no que é igual em todos os modos: o componente.

Anatomia de um componente

Um componente Razor é um arquivo .razor com marcação e código C#. O nome do arquivo é o nome da classe e começa com maiúscula:

@* Componentes/Contador.razor *@
<p>Você clicou @cliques vezes.</p>
<button @onclick="Incrementar">Clique</button>

@code {
    private int cliques;

    private void Incrementar() => cliques++;
}

A sintaxe Razor é a mesma de "Razor Pages": @ para expressões, @if e @foreach para controle de fluxo, e o bloco @code para membros da classe. Mudam duas coisas importantes.

O componente tem estado e reage a eventos. @onclick="Incrementar" liga o clique a um método C#. Depois que um tratador de evento roda, o Blazor renderiza o componente de novo automaticamente.

A renderização produz uma árvore, não texto. O compilador transforma o .razor numa classe derivada de ComponentBase, com um método BuildRenderTree que descreve os elementos. A cada renderização, o Blazor compara a árvore nova com a anterior (o diff) e envia ao navegador só as diferenças: aqui, trocar o texto "3" por "4" dentro do parágrafo. O resto do DOM não é tocado, então o foco, a posição de rolagem e o texto que o usuário está digitando em outro campo continuam como estavam.

O projeto: Blazor dentro do site

O site da Livraria é feito de Razor Pages, e não vamos reescrevê-lo. O caminho mais simples para acrescentar interatividade a uma página existente é o modelo clássico do Blazor Server, em que componentes são embutidos em páginas Razor. Ele ainda é suportado no .NET 10:

// Program.cs do Livraria.Site
builder.Services.AddRazorPages();
builder.Services.AddServerSideBlazor();

// ...
app.MapRazorPages();
app.MapBlazorHub();
@* no fim de Pages/Shared/_Layout.cshtml *@
<script src="_framework/blazor.server.js"></script>
@* Componentes/_Imports.razor: usings comuns a todos os componentes *@
@using Microsoft.AspNetCore.Components.Web
@using Livraria.Contratos
@using Livraria.Site.Servicos

No Blazor Server, o componente roda no servidor. O script abre uma conexão SignalR, o mesmo mecanismo de "Tempo real com SignalR", chamada de circuito. Cliques e digitação viajam pelo circuito até o servidor, os tratadores de evento rodam em C# no servidor, e os diffs da árvore voltam pelo circuito e são aplicados ao DOM. O navegador executa só um script pequeno, fixo e genérico.

Uma página Razor hospeda o componente com o tag helper <component>:

@* Pages/Carrinho.cshtml *@
@page "/carrinho"
@model CarrinhoModel
@{ ViewData["Title"] = "Seu carrinho"; }

<h1>Seu carrinho</h1>
<component type="typeof(Livraria.Site.Componentes.Carrinho)"
           render-mode="ServerPrerendered"
           param-CarrinhoId="@Model.CarrinhoId" />

ServerPrerendered faz o servidor mandar o HTML do componente já pronto na resposta, como uma Razor Page comum, e depois o circuito o torna interativo. O visitante vê o carrinho imediatamente, e buscadores e leitores de tela recebem conteúdo real. Os atributos param-* passam parâmetros ao componente. O próximo artigo detalha os modos de renderização e o custo do pré-render.

De onde vem o carrinho

O carrinho precisa sobreviver entre páginas: o cliente adiciona um livro na página de detalhe, continua navegando e só depois abre /carrinho. Como as Razor Pages recarregam a página a cada navegação, o estado não pode viver na memória de um componente. Ele fica num armazenamento no servidor, identificado por um cookie:

public sealed record ItemGuardado(int ProdutoId, int Quantidade);

public interface ICarrinhos
{
    Task<IReadOnlyList<ItemGuardado>> ObterAsync(string carrinhoId, CancellationToken ct);
    Task SalvarAsync(string carrinhoId, IReadOnlyList<ItemGuardado> itens, CancellationToken ct);
}

public sealed class CarrinhosDistribuidos(IDistributedCache cache) : ICarrinhos
{
    private static readonly DistributedCacheEntryOptions Validade =
        new() { SlidingExpiration = TimeSpan.FromDays(30) };

    public async Task<IReadOnlyList<ItemGuardado>> ObterAsync(string id, CancellationToken ct)
    {
        var bytes = await cache.GetAsync($"site:carrinho:{id}", ct);
        return bytes is null ? [] : JsonSerializer.Deserialize<List<ItemGuardado>>(bytes)!;
    }

    public Task SalvarAsync(string id, IReadOnlyList<ItemGuardado> itens, CancellationToken ct) =>
        cache.SetAsync($"site:carrinho:{id}", JsonSerializer.SerializeToUtf8Bytes(itens), Validade, ct);
}

Em desenvolvimento, AddDistributedMemoryCache(). Em produção, o AddStackExchangeRedisCache de "Cache com Redis, HybridCache e output caching", com um prefixo próprio do site. O carrinho guarda só ids e quantidades. Títulos e preços são buscados na API na hora de exibir, então um carrinho guardado há duas semanas mostra o preço de hoje. O pedido final, de qualquer forma, usa o preço que a API calcular no momento da venda.

O botão "Adicionar ao carrinho" na página de detalhe é um formulário POST comum, com antiforgery e Post/Redirect/Get, exatamente como em "Formulários e validação". O handler lê ou cria o cookie carrinho com um identificador aleatório (HttpOnly, Secure, SameSite=Lax), acrescenta o item e redireciona para /carrinho. O CarrinhoModel da página só lê o cookie e o repassa como parâmetro ao componente.

Parâmetros: dados que descem

Componentes se compõem em árvore, e os dados descem da raiz para as folhas por parâmetros, propriedades públicas marcadas com [Parameter]. O menor componente do carrinho é um seletor de quantidade reutilizável:

@* Componentes/SeletorDeQuantidade.razor *@
<div class="seletor" role="group" aria-label="@Rotulo">
    <button type="button" @onclick="() => Mudar(Valor - 1)"
            disabled="@(Valor <= Minimo || Desabilitado)" aria-label="Diminuir">−</button>
    <output aria-live="polite">@Valor</output>
    <button type="button" @onclick="() => Mudar(Valor + 1)"
            disabled="@(Valor >= Maximo || Desabilitado)" aria-label="Aumentar">+</button>
</div>

@code {
    [Parameter] public int Valor { get; set; }
    [Parameter] public EventCallback<int> ValorChanged { get; set; }
    [Parameter] public int Minimo { get; set; } = 1;
    [Parameter] public int Maximo { get; set; } = 20;
    [Parameter] public bool Desabilitado { get; set; }
    [Parameter, EditorRequired] public string Rotulo { get; set; } = "";

    private Task Mudar(int novo) =>
        novo >= Minimo && novo <= Maximo ? ValorChanged.InvokeAsync(novo) : Task.CompletedTask;
}

Três convenções aparecem aqui.

[EditorRequired] faz o compilador avisar quem usar o componente sem informar o parâmetro. É um aviso de compilação, não uma checagem em tempo de execução, mas pega a maior parte dos esquecimentos.

Um componente nunca altera o próprio parâmetro. Repare que Mudar não faz Valor = novo. O valor pertence ao componente pai. Se o filho o alterasse, a próxima renderização do pai sobrescreveria a alteração com o valor dele, e os dois ficariam brigando. O filho pede a mudança pelo callback, e o pai decide.

Valor + ValorChanged habilitam o @bind. Um parâmetro X acompanhado de um EventCallback<T> chamado XChanged permite ao pai escrever <SeletorDeQuantidade @bind-Valor="quantidade" />, a mesma ligação de mão dupla que os campos HTML têm.

Eventos: pedidos que sobem

EventCallback<T> é o mecanismo pelo qual um filho avisa o pai. Parece um delegate, e é quase isso, com uma diferença: ao ser invocado, ele renderiza de novo o componente que o forneceu (o pai). Com um Action<int> comum, o pai mudaria de estado e continuaria mostrando o valor antigo.

Uma linha do carrinho usa o seletor e sobe dois eventos:

@* Componentes/LinhaDoCarrinho.razor *@
<li class="linha">
    <a href="@Linha.Url">@Linha.Titulo</a>
    <span class="preco">@Linha.PrecoUnitario.ToString("C")</span>

    <SeletorDeQuantidade Valor="Linha.Quantidade"
                         ValorChanged="QuantidadeAlterada"
                         Maximo="Math.Min(20, Linha.Estoque)"
                         Desabilitado="Ocupado"
                         Rotulo="@($"Quantidade de {Linha.Titulo}")" />

    <span class="subtotal">@Linha.Subtotal.ToString("C")</span>
    <button type="button" class="remover" @onclick="Removido" disabled="@Ocupado">Remover</button>

    @if (Linha.Quantidade > Linha.Estoque)
    {
        <p class="aviso" role="alert">Só temos @Linha.Estoque em estoque.</p>
    }
</li>

@code {
    [Parameter, EditorRequired] public LinhaVisivel Linha { get; set; } = default!;
    [Parameter] public EventCallback<int> QuantidadeAlterada { get; set; }
    [Parameter] public EventCallback Removido { get; set; }
    [Parameter] public bool Ocupado { get; set; }
}
public sealed record LinhaVisivel(
    int ProdutoId, string Titulo, string Url, decimal PrecoUnitario, int Quantidade, int Estoque)
{
    public decimal Subtotal => PrecoUnitario * Quantidade;
}

LinhaVisivel é um record imutável. Quando a quantidade muda, o pai cria uma linha nova com with { Quantidade = n }. Imutabilidade combina bem com componentes: o Blazor decide se um filho precisa renderizar de novo comparando os parâmetros, e objetos que nunca mudam por dentro tornam essa decisão simples e correta.

O componente principal e o ciclo de vida

@* Componentes/Carrinho.razor *@
@implements IDisposable
@inject ICarrinhos Carrinhos
@inject ICatalogoDaLoja Catalogo

@if (linhas is null)
{
    <p aria-busy="true">Carregando seu carrinho…</p>
}
else if (linhas.Count == 0)
{
    <p>Seu carrinho está vazio. <a href="/">Que tal dar uma olhada na vitrine?</a></p>
}
else
{
    <ul class="carrinho">
        @foreach (var linha in linhas)
        {
            <LinhaDoCarrinho @key="linha.ProdutoId"
                             Linha="linha"
                             Ocupado="salvando"
                             QuantidadeAlterada="q => AlterarAsync(linha.ProdutoId, q)"
                             Removido="() => RemoverAsync(linha.ProdutoId)" />
        }
    </ul>

    <ResumoDoPedido Subtotal="Subtotal" QuantidadeDeItens="linhas.Sum(l => l.Quantidade)" />
}

@code {
    [Parameter, EditorRequired] public string CarrinhoId { get; set; } = "";

    private List<LinhaVisivel>? linhas;
    private bool salvando;
    private readonly CancellationTokenSource cancelamento = new();

    private decimal Subtotal => linhas?.Sum(l => l.Subtotal) ?? 0m;

    protected override async Task OnInitializedAsync()
    {
        var ct = cancelamento.Token;
        var itens = await Carrinhos.ObterAsync(CarrinhoId, ct);

        var produtos = await Task.WhenAll(itens.Select(i => Catalogo.ObterAsync(i.ProdutoId, ct)));

        linhas = itens.Zip(produtos)
            .Where(par => par.Second is not null)          // produto removido do catálogo
            .Select(par => new LinhaVisivel(
                par.First.ProdutoId, par.Second!.Titulo,
                $"/livros/{par.First.ProdutoId}/{Slugs.De(par.Second.Titulo)}",
                par.Second.Preco, par.First.Quantidade, par.Second.Estoque))
            .ToList();
    }

    private async Task AlterarAsync(int produtoId, int quantidade)
    {
        var i = linhas!.FindIndex(l => l.ProdutoId == produtoId);
        linhas[i] = linhas[i] with { Quantidade = quantidade };
        await SalvarAsync();
    }

    private async Task RemoverAsync(int produtoId)
    {
        linhas!.RemoveAll(l => l.ProdutoId == produtoId);
        await SalvarAsync();
    }

    private async Task SalvarAsync()
    {
        salvando = true;
        try
        {
            await Carrinhos.SalvarAsync(CarrinhoId,
                linhas!.Select(l => new ItemGuardado(l.ProdutoId, l.Quantidade)).ToList(),
                cancelamento.Token);
        }
        finally
        {
            salvando = false;
        }
    }

    public void Dispose()
    {
        cancelamento.Cancel();
        cancelamento.Dispose();
    }
}

@inject pede serviços à DI, a mesma do resto da aplicação. Os métodos de ciclo de vida são chamados pelo Blazor em momentos definidos:

Método Quando Uso típico
SetParametersAsync antes de tudo, a cada conjunto de parâmetros raramente sobrescrito
OnInitialized(Async) uma vez, na primeira vez que o componente recebe parâmetros carregar dados
OnParametersSet(Async) depois de OnInitialized e a cada mudança de parâmetros vinda do pai recalcular o que depende de parâmetros
ShouldRender antes de cada renderização, exceto a primeira otimização: devolver false evita o diff
OnAfterRender(Async)(bool firstRender) depois que o DOM foi atualizado chamar JavaScript, medir elementos
Dispose / DisposeAsync quando o componente sai da árvore cancelar trabalho e soltar assinaturas

Alguns detalhes que o código acima aproveita:

Renderização durante o carregamento. Um OnInitializedAsync que faz await provoca duas renderizações: uma quando o primeiro await cede o controle (com linhas ainda nulo, daí o "Carregando…") e outra quando o método termina. É por isso que o estado de carregamento precisa ser tratado no template.

@key em listas. Sem ele, o Blazor compara listas por posição. Ao remover o primeiro livro do carrinho, ele concluiria que a primeira linha "mudou de conteúdo" para o segundo livro, a segunda para o terceiro, e assim por diante, e reaproveitaria os elementos errados: um foco ou uma animação ficaria na linha errada. Com @key="linha.ProdutoId", ele sabe exatamente qual linha saiu.

Task.WhenAll busca todos os produtos em paralelo, e não um depois do outro. Com cinco livros, é a diferença entre a latência de uma chamada e a de cinco. O ICatalogoDaLoja é o de "Razor Pages", e no próximo artigo ele ganha cache.

Cancelamento no Dispose. Se o usuário sair da página enquanto o carregamento está em andamento, o componente é descartado e o token cancela as chamadas pendentes. É o mesmo cuidado de "Assincronia Avançada": trabalho cujo resultado ninguém vai ver deve parar.

salvando desabilita os botões durante a gravação. No Blazor Server, os eventos de um circuito são processados um de cada vez, no contexto de sincronização do circuito. Mas cada await devolve o controle, e um segundo clique pode entrar enquanto o primeiro salva. Desabilitar os controles evita estados intermediários confusos e gravações que se sobrepõem.

Composição: fragmentos de conteúdo

Componentes também podem receber marcação como parâmetro, pelo tipo RenderFragment. É assim que se constroem invólucros reutilizáveis:

@* Componentes/Painel.razor *@
<section class="painel" aria-labelledby="@id">
    <h2 id="@id">@Titulo</h2>
    @ChildContent
    @if (Rodape is not null) { <footer>@Rodape</footer> }
</section>

@code {
    private readonly string id = $"painel-{Guid.NewGuid():N}";
    [Parameter, EditorRequired] public string Titulo { get; set; } = "";
    [Parameter] public RenderFragment? ChildContent { get; set; }
    [Parameter] public RenderFragment? Rodape { get; set; }
}
<Painel Titulo="Resumo do pedido">
    <ChildContent>
        <p>Subtotal: @Subtotal.ToString("C")</p>
    </ChildContent>
    <Rodape>
        <button type="button">Finalizar compra</button>
    </Rodape>
</Painel>

O parâmetro chamado ChildContent é especial: o conteúdo entre as tags vai para ele sem precisar nomeá-lo. RenderFragment<T> vai um passo além e recebe um valor, o que permite componentes com template, como uma tabela genérica Grade<TItem> que decide a estrutura e deixa o chamador definir como cada item aparece. É a mesma ideia de "Genéricos: Um Código, Muitos Tipos, Nenhuma Conversão", aplicada à interface.

Para dados que muitos componentes de uma subárvore precisam, como o tema visual, a cultura ou o usuário logado, passar parâmetro por parâmetro em cada nível fica tedioso. <CascadingValue Value="..."> disponibiliza um valor a todos os descendentes, que o recebem com [CascadingParameter]. Use com moderação: parâmetros explícitos deixam claro de onde vêm os dados, e valores em cascata os escondem.

O resumo e o formulário de CEP

O ResumoDoPedido reaproveita o ICotadorDeFrete de "Formulários e validação", agora de forma interativa:

@* Componentes/ResumoDoPedido.razor *@
@inject ICotadorDeFrete Cotador

<Painel Titulo="Resumo">
    <p>@QuantidadeDeItens @(QuantidadeDeItens == 1 ? "item" : "itens"): @Subtotal.ToString("C")</p>

    <EditForm Model="entrada" OnValidSubmit="CotarAsync" FormName="frete-carrinho">
        <DataAnnotationsValidator />
        <label for="cep">CEP</label>
        <InputText id="cep" @bind-Value="entrada.Cep" inputmode="numeric" autocomplete="postal-code" />
        <ValidationMessage For="() => entrada.Cep" />
        <button type="submit" disabled="@cotando">Calcular frete</button>
    </EditForm>

    @if (cotacao is not null)
    {
        <p role="status">Frete: @cotacao.Valor.ToString("C") (@cotacao.Transportadora)</p>
        <p class="total">Total: @((Subtotal + cotacao.Valor).ToString("C"))</p>
    }
    @if (erro is not null) { <p role="alert">@erro</p> }
</Painel>

@code {
    [Parameter] public decimal Subtotal { get; set; }
    [Parameter] public int QuantidadeDeItens { get; set; }

    private readonly EntradaDeFrete entrada = new();
    private CotacaoResposta? cotacao;
    private string? erro;
    private bool cotando;
    private int quantidadeCotada;

    protected override void OnParametersSet()
    {
        if (cotacao is not null && QuantidadeDeItens != quantidadeCotada)
            cotacao = null;          // a cotação anterior não vale mais
    }

    private async Task CotarAsync()
    {
        cotando = true;
        erro = null;
        try
        {
            var resultado = await Cotador.CotarCarrinhoAsync(entrada.Cep, QuantidadeDeItens, default);
            (cotacao, erro) = resultado switch
            {
                ResultadoDeCotacao.Sucesso s => (s.Cotacao, (string?)null),
                ResultadoDeCotacao.Invalida i => (null, i.Mensagem),
                _ => (null, "Não foi possível calcular o frete agora.")
            };
            quantidadeCotada = QuantidadeDeItens;
        }
        finally
        {
            cotando = false;
        }
    }
}

EditForm, DataAnnotationsValidator, InputText e ValidationMessage são os componentes de formulário do Blazor. Eles leem os mesmos atributos de EntradaDeFrete usados na Razor Page do artigo anterior, e a validação acontece a cada alteração, sem ida ao servidor no WebAssembly e sem recarregar a página no Server. A mesma classe de entrada serve aos dois mundos.

O OnParametersSet mostra o uso típico desse método: quando o pai muda a quantidade de itens, o frete calculado para a quantidade anterior deixa de valer e é descartado. OnInitialized não serviria, porque só roda uma vez.

Estilos isolados

Um arquivo LinhaDoCarrinho.razor.css ao lado do componente tem escopo automático: o build acrescenta um atributo único aos elementos do componente e reescreve os seletores do CSS para exigir esse atributo.

/* Componentes/LinhaDoCarrinho.razor.css */
.linha { display: grid; grid-template-columns: 1fr auto auto auto auto; gap: .75rem; align-items: center; }
.aviso { grid-column: 1 / -1; color: var(--cor-alerta); }

A classe .linha deste arquivo não afeta nenhum .linha de outro componente, e vice-versa. O build reúne todos os estilos isolados num arquivo Livraria.Site.styles.css, que o layout referencia uma vez com <link rel="stylesheet" href="~/Livraria.Site.styles.css" asp-append-version="true" />.

A Livraria agora tem um carrinho interativo feito só de C#: componentes pequenos que recebem dados por parâmetros e devolvem pedidos por EventCallback, um ciclo de vida que carrega, recalcula e cancela no momento certo, listas estáveis com @key e validação que reaproveita os atributos das páginas. Ficaram perguntas em aberto. Cada visitante com o carrinho aberto mantém um circuito e memória no servidor: quantos visitantes simultâneos isso aguenta? O carregamento roda duas vezes com o pré-render? E se o mesmo componente rodasse no navegador, sem servidor nenhum?

Fontes e leituras recomendadas

Exercícios

Exercício 1

Um desenvolvedor escreveu no SeletorDeQuantidade o método private void Mudar(int novo) { Valor = novo; }, sem callback. O botão parece funcionar: o número muda. Descreva o bug que aparece depois e explique a causa.

Ver resposta

✓ Resposta: O número muda porque o filho renderiza a si mesmo com o próprio Valor alterado. Mas o pai (LinhaDoCarrinho e, acima dele, o Carrinho) nunca fica sabendo: linhas não muda, o subtotal e o total continuam errados, e nada é salvo. Pior: na próxima vez que o pai renderizar por qualquer motivo, como outro item alterado ou a cotação de frete, ele passa de novo Valor="Linha.Quantidade" com o valor antigo, e o seletor "volta" sozinho. E nada avisa: o compilador aceita a atribuição em silêncio (no .NET 10, nem um aviso de build). A causa é que o parâmetro pertence ao pai, e o filho só pode pedir mudanças por ValorChanged.InvokeAsync(novo), deixando o pai atualizar a fonte da verdade e repassar o valor novo.

Exercício 2

Remova o @key do foreach do carrinho. Com três livros (A, B e C), um usuário de teclado leva o foco até o botão "Remover" do livro A e tecla Enter. Onde o foco fica depois da remoção, sem @key e com ele, e por quê?

Ver resposta

✓ Resposta: Sem @key, o Blazor compara as listas por posição. A lista antiga tinha A, B, C e a nova tem B, C. Ele conclui que o elemento da posição 1 mudou de A para B, o da posição 2 mudou de B para C, e o da posição 3 foi removido. Então atualiza o conteúdo dos dois primeiros elementos e apaga o terceiro. O botão com foco é o elemento físico da posição 1, que continua no DOM e agora exibe o livro B: o foco "pula" para o "Remover" de B, e um segundo Enter apagaria um livro que o usuário nem viu. Com @key, o Blazor sabe que A saiu: remove o elemento de A, com o botão que tinha o foco, e preserva intactos os de B e C. O foco volta para o <body>, o que também desorienta quem usa teclado, mas de forma previsível, e o componente pode então movê-lo de propósito (com @ref e ElementReference.FocusAsync() no OnAfterRenderAsync) para a linha seguinte ou para o título do carrinho. O @key garante que cada elemento continue representando o mesmo livro. Para onde o foco vai depois de uma remoção é uma decisão de acessibilidade que o componente precisa tomar explicitamente. (Remover com o mouse não muda muito: no Chrome e no Firefox, clicar num botão também lhe dá o foco.)

Exercício 3

Por que o componente mostra "Carregando seu carrinho…" mesmo com o OnInitializedAsync completo, e em que condição essa mensagem nunca apareceria?

Ver resposta

✓ Resposta: O Blazor renderiza o componente assim que OnInitializedAsync devolve uma Task ainda incompleta, isto é, no primeiro await que não termina de forma síncrona. Nesse momento linhas ainda é nulo, e o template mostra a mensagem. Quando a tarefa termina, ele renderiza de novo com os dados. A mensagem nunca apareceria se todas as operações aguardadas terminassem de forma síncrona, por exemplo com o carrinho e os produtos já em cache, retornados por ValueTask/Task completadas. Aí o método termina sem ceder o controle e há uma única renderização. Com ServerPrerendered, o visitante também nunca vê a mensagem no HTML inicial, porque o pré-render espera o OnInitializedAsync terminar antes de enviar a resposta.

Exercício 4

Crie um componente com template Lista<TItem> com os parâmetros Itens (IReadOnlyList<TItem>), ItemTemplate (RenderFragment<TItem>) e Vazio (RenderFragment?), e reescreva o foreach do carrinho com ele. Que diretiva o componente precisa?

Ver resposta

✓ Resposta: O componente começa com @typeparam TItem, que o torna genérico. Parâmetros: [Parameter, EditorRequired] public IReadOnlyList<TItem> Itens { get; set; } = [];, [Parameter, EditorRequired] public RenderFragment<TItem> ItemTemplate { get; set; } = default!; e [Parameter] public RenderFragment? Vazio { get; set; }. Marcação: @if (Itens.Count == 0) { @Vazio } else { <ul>@foreach (var item in Itens) { @ItemTemplate(item) }</ul> }. Uso: <Lista Itens="linhas" Context="linha"><ItemTemplate><LinhaDoCarrinho @key="linha.ProdutoId" Linha="linha" ... /></ItemTemplate><Vazio><p>Seu carrinho está vazio.</p></Vazio></Lista>. TItem é inferido de Itens, e Context="linha" dá nome ao parâmetro do template, que por padrão se chamaria context. O @key precisa ficar no elemento raiz do template, e não na lista.

Exercício 5

No Carrinho, o usuário clica "+" três vezes muito rápido enquanto a primeira gravação ainda está em andamento. Sem a variável salvando, que sequência de estados é possível no armazenamento? Por que isso não corrompe o carrinho, mas ainda assim é um problema?

Ver resposta

✓ Resposta: Cada clique altera linhas (quantidade 2, 3, 4) e chama SalvarAsync. Como o circuito processa um evento por vez, mas cada await cede o controle, as três gravações podem ficar em andamento ao mesmo tempo. Como são operações independentes de rede, podem terminar fora de ordem: se a gravação com quantidade 3 chegar ao Redis por último, o armazenamento fica com 3 enquanto a tela mostra 4. Não há corrupção, porque cada gravação escreve uma lista completa e consistente. O problema é a divergência entre a tela e o armazenamento: ao recarregar ou ao finalizar a compra, o cliente vê 3 livros em vez de 4. Desabilitar os controles durante a gravação serializa as operações. Alternativas mais sofisticadas: um debounce que grava só depois de 500 ms sem novos cliques, ou um número de versão por gravação que faz o armazenamento recusar escritas mais antigas que a última aplicada, o mesmo raciocínio do exercício 5 de "Tempo real com SignalR".

Comentários

Mais em Linguagem C#

Dapper: SQL Seu, Sem a Cerimônia
Dapper: SQL Seu, Sem a Cerimônia

Como o Dapper elimina a cerimônia do ADO.NET sem esconder o SQL: QueryAsync…

Input, física e colisões: fazendo o jogo parecer justo
Input, física e colisões: fazendo o jogo parecer justo

O que faz um jogo parecer justo: ações no lugar de teclas, pulo com buffer…

Memória no .NET: Pilha, Heap, Coletor de Lixo e IDisposable
Memória no .NET: Pilha, Heap, Coletor de Lixo e IDisposable

Memória no .NET: o que fica na pilha e no heap, por que alocar é barato e…