Introdução

No artigo anterior desta série, analisei se Blazor WebAssembly está pronto para produção corporativa comparando-o com Angular. A conclusão foi nuançada — Blazor WASM é viável para cenários corporativos internos, mas tem trade-offs que precisam ser avaliados caso a caso. Hoje vou provar na prática construindo um CRUD completo de produtos com DataGrid paginado, formulários com validação, dialogs, notificações e inline editing — tudo em C#, sem escrever uma linha de JavaScript.

O componente de UI que escolhi é o Radzen Blazor, uma biblioteca open source (licença MIT) com mais de 70 componentes gratuitos. A razão principal: Radzen entrega uma experiência visual madura para cenários CRUD corporativos, com DataGrid, formulários, validação, dialogs e notificações prontos para uso. O JavaScript que roda internamente (Radzen.Blazor.js) é da própria biblioteca — o desenvolvedor nunca toca em JS diretamente.

O que vou construir neste tutorial:

  • API REST com Minimal API para Produtos e Categorias (10 endpoints)
  • Blazor WASM Standalone consumindo a API via HttpClient
  • DataGrid paginado com busca, ordering e ações por linha
  • Formulário em dialog reutilizável para criação e edição
  • Inline editing para Categorias (pattern alternativo ao dialog)
  • Exclusão com confirmação e notificações visuais

Todo o código está no repositório blog-zocateli-sample no GitHub. A ideia é que você clone, rode e forme sua própria opinião. Este artigo é o 2º da série “Frontend Moderno” — meu objetivo é demonstrar que o ecossistema de componentes Blazor já suporta cenários reais de produção, com uma experiência de desenvolvimento familiar para quem vem do .NET.

ℹ️ Informação: Radzen Blazor é open source (MIT) e inclui 70+ componentes free. O Radzen.Blazor.js é JavaScript interno da biblioteca — o desenvolvedor nunca escreve JavaScript diretamente. A versão usada neste tutorial é a 10.2.0 com .NET 10.


Pré-requisitos

Para acompanhar este tutorial, você vai precisar de:

  • .NET 10 SDK (10.0.201 ou superior) — download oficial
  • IDE: VS Code com extensão C# Dev Kit, ou Visual Studio 2022 17.14+
  • Conhecimento básico de C# e REST APIs
  • Terminal (PowerShell, bash ou zsh)

Clone o repositório com todo o código pronto:

1
2
git clone https://github.com/lzocateli/blog-zocateli-sample.git
cd blog-zocateli-sample

Verifique se o SDK está instalado:

1
dotnet --version

Output esperado:

1
10.0.201

💡 Dica: Se você usa o VS Code com Dev Containers, o .devcontainer/ do repositório já tem o .NET 10 SDK configurado. Basta abrir o projeto no container e tudo estará pronto.


Criando o Projeto Blazor WASM Standalone

O template blazorwasm do .NET cria uma aplicação Blazor WebAssembly Standalone — uma SPA que roda inteiramente no browser via WebAssembly, sem servidor ASP.NET Core hospedando. Diferente do modelo Hosted (que inclui um projeto Server), o Standalone é uma SPA pura que consome APIs externas via HTTP, exatamente como uma aplicação Angular ou React.

Scaffolding do projeto

1
2
3
dotnet new blazorwasm --name BlogSamples.BlazorWasm --output frontend/blazor-wasm --framework net10.0
dotnet sln add frontend/blazor-wasm
dotnet add frontend/blazor-wasm package Radzen.Blazor

O primeiro comando cria o projeto, o segundo adiciona à solution e o terceiro instala o Radzen Blazor — a biblioteca de componentes UI. O .csproj resultante fica enxuto:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
<Project Sdk="Microsoft.NET.Sdk.BlazorWebAssembly">
  <PropertyGroup>
    <TargetFramework>net10.0</TargetFramework>
    <Nullable>enable</Nullable>
    <ImplicitUsings>enable</ImplicitUsings>
  </PropertyGroup>
  <ItemGroup>
    <PackageReference Include="Microsoft.AspNetCore.Components.WebAssembly" Version="10.0.5" />
    <PackageReference Include="Microsoft.AspNetCore.Components.WebAssembly.DevServer" Version="10.0.5" PrivateAssets="all" />
    <PackageReference Include="Radzen.Blazor" Version="10.2.0" />
  </ItemGroup>
</Project>

Configurando Program.cs

O Program.cs é o entry point da SPA. Aqui configuro o HttpClient com a URL base da API (via appsettings.json), registro os services HTTP tipados e adiciono os componentes Radzen:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
using Microsoft.AspNetCore.Components.Web;
using Microsoft.AspNetCore.Components.WebAssembly.Hosting;
using BlogSamples.BlazorWasm;
using BlogSamples.BlazorWasm.Services;
using Radzen;

var builder = WebAssemblyHostBuilder.CreateDefault(args);
builder.RootComponents.Add<App>("#app");
builder.RootComponents.Add<HeadOutlet>("head::after");

// Configurar HttpClient com URL base da API
var apiBaseUrl = builder.Configuration["ApiBaseUrl"] ?? "http://localhost:5101";
builder.Services.AddScoped(sp => new HttpClient { BaseAddress = new Uri(apiBaseUrl) });

// Services HTTP tipados
builder.Services.AddScoped<ProdutoApiService>();
builder.Services.AddScoped<CategoriaApiService>();

// Radzen Components (DialogService, NotificationService, etc.)
builder.Services.AddRadzenComponents();

await builder.Build().RunAsync();

A chamada AddRadzenComponents() registra automaticamente DialogService, NotificationService, TooltipService e ContextMenuService no container de DI. Sem ela, os dialogs e notificações não funcionam.

Layout com Radzen

O MainLayout.razor define a estrutura visual da aplicação — header com toggle de sidebar, navegação lateral com RadzenPanelMenu, área de conteúdo e footer:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
@inherits LayoutComponentBase

<RadzenLayout>
    <RadzenHeader>
        <RadzenStack Orientation="Orientation.Horizontal" AlignItems="AlignItems.Center"
                     Gap="0.5rem" class="rz-p-2">
            <RadzenSidebarToggle Click="@(() => sidebarExpanded = !sidebarExpanded)" />
            <RadzenText Text="Blog Samples — Blazor WASM" TextStyle="TextStyle.H5"
                        class="rz-m-0" />
        </RadzenStack>
    </RadzenHeader>

    <RadzenSidebar @bind-Expanded="@sidebarExpanded">
        <RadzenPanelMenu>
            <RadzenPanelMenuItem Text="Dashboard" Icon="dashboard" Path="/" />
            <RadzenPanelMenuItem Text="Produtos" Icon="inventory_2" Path="/produtos" />
            <RadzenPanelMenuItem Text="Categorias" Icon="category" Path="/categorias" />
        </RadzenPanelMenu>
    </RadzenSidebar>

    <RadzenBody>
        <div class="rz-p-4">
            @Body
        </div>
    </RadzenBody>

    <RadzenFooter>
        <RadzenText Text="© 2026 Blog Samples — zocate.li" TextStyle="TextStyle.Caption"
                    class="rz-p-2" />
    </RadzenFooter>
</RadzenLayout>

<RadzenComponents />

@code {
    bool sidebarExpanded = true;
}

O componente <RadzenComponents /> no final é obrigatório — ele renderiza os containers para dialogs, notificações e tooltips. Sem ele, DialogService.OpenAsync() e NotificationService.Notify() não exibem nada na tela.

⚠️ Atenção: O <RadzenComponents /> deve estar dentro do layout, não no App.razor. Colocá-lo fora do layout pode causar problemas de renderização com dialogs e notificações.

Para que o tema visual funcione, o App.razor precisa incluir <RadzenTheme Theme="material" />:

1
2
3
4
<RadzenTheme Theme="material" />
<Router AppAssembly="typeof(Program).Assembly">
    <!-- ... -->
</Router>

O tema material do Radzen inclui toda a estilização necessária — cores, tipografia, espaçamento, ícones Material Design. Não é necessário importar Bootstrap ou qualquer outro framework CSS.


A API REST — Domínio Produtos

Para o Blazor WASM consumir dados, criei um domínio Produtos com Minimal API no projeto principal. São 10 endpoints organizados em dois grupos:

VerboRotaDescrição
GET/api/produtos?pagina=1&tamanhoPagina=20&filtro=Listar com paginação e filtro
GET/api/produtos/{id}Obter por ID
POST/api/produtosCriar produto
PUT/api/produtos/{id}Atualizar produto
DELETE/api/produtos/{id}Remover produto
GET/api/categoriasListar todas
GET/api/categorias/{id}Obter por ID
POST/api/categoriasCriar categoria
PUT/api/categorias/{id}Atualizar categoria
DELETE/api/categorias/{id}Remover categoria

O ProdutoEndpoints.cs usa MapGroup para organizar as rotas e ProducesResponseType para documentar no Swagger:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
public static class ProdutoEndpoints
{
    public static void MapProdutoEndpoints(this IEndpointRouteBuilder app)
    {
        var produtos = app.MapGroup("/api/produtos")
            .WithTags("Produtos");

        produtos.MapGet("/", async (
            IProdutoService service,
            int pagina = 1,
            int tamanhoPagina = 20,
            string? filtro = null) =>
        {
            var resultado = await service.ListarProdutosAsync(pagina, tamanhoPagina, filtro);
            return Results.Ok(resultado);
        })
        .WithName("ListarProdutos")
        .Produces<PagedResult<ProdutoDto>>();

        produtos.MapPost("/", async (CriarProdutoRequest request, IProdutoService service) =>
        {
            var produto = await service.CriarProdutoAsync(request);
            return Results.CreatedAtRoute("ObterProduto", new { id = produto.Id }, produto);
        })
        .WithName("CriarProduto")
        .Produces<ProdutoDto>(201)
        .ProducesValidationProblem();

        // ... PUT, DELETE, e endpoints de Categorias seguem o mesmo pattern
    }
}

Os DTOs de request usam DataAnnotations para validação server-side, garantindo que a API valide os dados mesmo que o client-side seja bypassed:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
public record CriarProdutoRequest
{
    [Required(ErrorMessage = "Nome é obrigatório")]
    [StringLength(200, MinimumLength = 3)]
    public string Nome { get; init; } = string.Empty;

    public string? Descricao { get; init; }

    [Range(0.01, double.MaxValue, ErrorMessage = "Preço deve ser maior que zero")]
    public decimal Preco { get; init; }

    [Range(0, int.MaxValue)]
    public int QuantidadeEstoque { get; init; }

    [Range(1, int.MaxValue, ErrorMessage = "Selecione uma categoria")]
    public int CategoriaId { get; init; }

    public bool Ativo { get; init; } = true;
}

A implementação do service usa ConcurrentDictionary como storage in-memory (decisão de design para manter o tutorial focado no Blazor WASM, sem dependência de banco de dados). O seed inicial inclui 8 categorias e mais de 50 produtos distribuídos entre elas.

Configuração CORS

Como o Blazor WASM Standalone roda em uma porta diferente da API (5200 vs 5101), é obrigatório configurar CORS no Program.cs da API:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
builder.Services.AddCors(options =>
{
    options.AddPolicy("BlazorWasm", policy =>
    {
        policy.WithOrigins("http://localhost:5200", "https://localhost:7200")
              .AllowAnyHeader()
              .AllowAnyMethod();
    });
});

// No pipeline:
app.UseCors("BlazorWasm");

💡 Dica: Configurar CORS é obrigatório para Blazor WASM Standalone. Sem configuração explícita, o browser bloqueará as requisições cross-origin. Em produção, substitua os origins por domínios reais.

Para testar a API isoladamente, rode dotnet run --project src/BlogSamples e acesse http://localhost:5101/docs — o Swagger mostra todos os endpoints de Produtos e Categorias.

O diagrama abaixo mostra a arquitetura completa — o Blazor WASM no browser se comunica com a API REST via HttpClient (JSON) atravessando a barreira de CORS:

Diagrama de arquitetura mostrando Blazor WASM comunicando com API REST via HttpClient e CORS


Services HTTP — Consumindo a API

O pattern que uso para consumir a API é service tipado com HttpClient injetado via primary constructor. Cada service encapsula as chamadas HTTP para um domínio específico, usando os métodos de extensão do System.Net.Http.JsonGetFromJsonAsync, PostAsJsonAsync e PutAsJsonAsync:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
using System.Net.Http.Json;
using BlogSamples.BlazorWasm.Models;

namespace BlogSamples.BlazorWasm.Services;

public class ProdutoApiService(HttpClient http)
{
    public async Task<PagedResult<ProdutoDto>> ListarAsync(
        int pagina = 1, int tamanhoPagina = 20, string? filtro = null)
    {
        var url = $"api/produtos?pagina={pagina}&tamanhoPagina={tamanhoPagina}";
        if (!string.IsNullOrWhiteSpace(filtro))
            url += $"&filtro={Uri.EscapeDataString(filtro)}";

        return await http.GetFromJsonAsync<PagedResult<ProdutoDto>>(url)
            ?? new PagedResult<ProdutoDto>();
    }

    public async Task<ProdutoDto?> ObterPorIdAsync(int id)
        => await http.GetFromJsonAsync<ProdutoDto>($"api/produtos/{id}");

    public async Task<ProdutoDto?> CriarAsync(CriarProdutoRequest request)
    {
        var response = await http.PostAsJsonAsync("api/produtos", request);
        response.EnsureSuccessStatusCode();
        return await response.Content.ReadFromJsonAsync<ProdutoDto>();
    }

    public async Task<ProdutoDto?> AtualizarAsync(int id, AtualizarProdutoRequest request)
    {
        var response = await http.PutAsJsonAsync($"api/produtos/{id}", request);
        response.EnsureSuccessStatusCode();
        return await response.Content.ReadFromJsonAsync<ProdutoDto>();
    }

    public async Task RemoverAsync(int id)
    {
        var response = await http.DeleteAsync($"api/produtos/{id}");
        response.EnsureSuccessStatusCode();
    }
}

Alguns pontos importantes sobre este pattern:

  • Uri.EscapeDataString no filtro previne injeção de parâmetros na query string. Nunca concatene strings diretamente em URLs sem encoding.
  • EnsureSuccessStatusCode() lança HttpRequestException se a API retornar erro (4xx, 5xx). No componente Blazor, capturo essa exceção para exibir notificação de erro ao usuário.
  • Primary constructor (HttpClient http) evita o boilerplate de campo + construtor. O HttpClient é resolvido pelo container de DI com a BaseAddress configurada no Program.cs.

O CategoriaApiService segue exatamente o mesmo pattern, com métodos ListarAsync, CriarAsync, AtualizarAsync e RemoverAsync.

No Angular, HttpClient com interceptors e operadores RxJS oferece ergonomia similar. Em Blazor, a experiência é equivalente — DelegatingHandler serve como interceptor para autenticação, logging ou retry. A diferença principal é que Blazor usa async/await nativo do C# em vez de Observable do RxJS.

ℹ️ Informação: Os models do Blazor WASM são classes (não records). Radzen Blazor usa two-way binding (@bind-Value) que requer setters mutáveis. Records com init não funcionam para edição em formulários Radzen.


DataGrid de Produtos com Radzen

O RadzenDataGrid é o componente central deste tutorial. Ele suporta paginação server-side, sorting, filtering, templates customizados por coluna e integração direta com o pattern de LoadData — um callback que o grid chama toda vez que precisa de dados novos (ao mudar de página, ordenar ou filtrar).

Aqui está o Produtos.razor completo — vou explicar cada parte:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
@page "/produtos"

<PageTitle>Produtos — Blog Samples</PageTitle>

<RadzenText TextStyle="TextStyle.H3" class="rz-mb-4">Produtos</RadzenText>

<RadzenStack Orientation="Orientation.Horizontal" AlignItems="AlignItems.Center"
             Gap="1rem" class="rz-mb-4">
    <RadzenTextBox Placeholder="Buscar produtos..." @bind-Value="filtro"
                   Change="@OnFiltroChanged" Style="width: 300px;" />
    <RadzenButton Text="Novo Produto" Icon="add" ButtonStyle="ButtonStyle.Primary"
                  Click="@(() => AbrirFormulario(null))" />
</RadzenStack>

<RadzenDataGrid @ref="grid" TItem="ProdutoDto"
                Data="@produtos" Count="@totalRegistros"
                LoadData="@CarregarDados"
                AllowPaging="true" PageSize="20"
                AllowSorting="true"
                PagerHorizontalAlign="HorizontalAlign.Center"
                IsLoading="@isLoading"
                Style="width: 100%;">
    <Columns>
        <RadzenDataGridColumn TItem="ProdutoDto" Property="Id" Title="ID"
                              Width="70px" TextAlign="TextAlign.Center" Sortable="false" />

        <RadzenDataGridColumn TItem="ProdutoDto" Property="Nome" Title="Nome"
                              MinWidth="200px" />

        <RadzenDataGridColumn TItem="ProdutoDto" Property="CategoriaNome" Title="Categoria"
                              Width="150px" />

        <RadzenDataGridColumn TItem="ProdutoDto" Property="Preco" Title="Preço"
                              Width="130px" TextAlign="TextAlign.End"
                              FormatString="{0:C2}" />

        <RadzenDataGridColumn TItem="ProdutoDto" Property="QuantidadeEstoque" Title="Estoque"
                              Width="100px" TextAlign="TextAlign.Center" />

        <RadzenDataGridColumn TItem="ProdutoDto" Property="Ativo" Title="Status"
                              Width="100px" TextAlign="TextAlign.Center" Sortable="false">
            <Template Context="produto">
                <RadzenBadge BadgeStyle="@(produto.Ativo ? BadgeStyle.Success : BadgeStyle.Light)"
                             Text="@(produto.Ativo ? "Ativo" : "Inativo")" />
            </Template>
        </RadzenDataGridColumn>

        <RadzenDataGridColumn TItem="ProdutoDto" Title="Ações" Width="140px"
                              TextAlign="TextAlign.Center" Sortable="false">
            <Template Context="produto">
                <RadzenButton Icon="edit" ButtonStyle="ButtonStyle.Light"
                              Size="ButtonSize.Small"
                              Click="@(() => AbrirFormulario(produto.Id))"
                              class="rz-mr-1" />
                <RadzenButton Icon="delete" ButtonStyle="ButtonStyle.Danger"
                              Size="ButtonSize.Small"
                              Click="@(() => ConfirmarExclusao(produto))" />
            </Template>
        </RadzenDataGridColumn>
    </Columns>
</RadzenDataGrid>

Vou detalhar os pontos-chave:

  • LoadData="@CarregarDados" — o grid chama este callback ao inicializar, ao mudar de página e ao ordenar. Recebe LoadDataArgs com Skip, Top e informações de ordering. Essa é a chave para paginação server-side.
  • Data + CountData recebe a página atual de itens; Count informa o total de registros. O grid calcula o número de páginas automaticamente.
  • FormatString="{0:C2}" — formata o preço como moeda. O Blazor WASM usa a cultura configurada no browser, então em pt-BR exibe “R$ 1.299,00”.
  • Template — colunas customizadas. Uso RadzenBadge para exibir o status como badge verde/cinza e botões de ação (Editar, Excluir) com ícones Material Design.
  • IsLoading — exibe um spinner enquanto a API está sendo chamada. Melhora significativamente a UX em conexões lentas.

O @code block contém a lógica:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
@code {
    RadzenDataGrid<ProdutoDto> grid = default!;
    IEnumerable<ProdutoDto> produtos = [];
    int totalRegistros;
    string? filtro;
    bool isLoading;

    [Inject] ProdutoApiService ProdutoService { get; set; } = default!;
    [Inject] DialogService DialogService { get; set; } = default!;
    [Inject] NotificationService NotificationService { get; set; } = default!;

    async Task CarregarDados(LoadDataArgs args)
    {
        isLoading = true;

        var pagina = (args.Skip ?? 0) / (args.Top ?? 20) + 1;
        var tamanhoPagina = args.Top ?? 20;

        var resultado = await ProdutoService.ListarAsync(pagina, tamanhoPagina, filtro);

        produtos = resultado.Itens;
        totalRegistros = resultado.TotalRegistros;

        isLoading = false;
    }

    async Task OnFiltroChanged()
    {
        await grid.FirstPage(true);
    }
}

O método CarregarDados converte Skip/Top (pattern do Radzen) para pagina/tamanhoPagina (pattern da minha API). Quando o usuário digita no campo de busca, OnFiltroChanged volta para a primeira página com grid.FirstPage(true) — o true força o reload que chama CarregarDados novamente com o filtro atualizado.

⚠️ Atenção: O evento LoadData do RadzenDataGrid é chamado toda vez que o grid precisa de dados — paging, sorting, filtering. Não confunda com Data binding direto, que é para dados client-side. Se você usar Data com uma lista completa e AllowPaging, a paginação será client-side (todos os dados carregados de uma vez). Com LoadData + Count, a paginação é server-side (apenas a página atual é carregada).


Formulário de Criação e Edição

O ProdutoForm.razor é um componente reutilizável que serve tanto para criar quanto para editar produtos. A distinção é feita pelo Parameter ProdutoId: se for null, é criação; se tiver valor, é edição. O formulário é aberto em um dialog modal via DialogService.OpenAsync<ProdutoForm>().

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
<RadzenTemplateForm TItem="CriarProdutoRequest" Data="@model" Submit="@OnSubmit">
    <RadzenStack Gap="1rem">
        <RadzenFormField Text="Nome" Variant="Variant.Outlined">
            <ChildContent>
                <RadzenTextBox @bind-Value="model.Nome" MaxLength="200" />
            </ChildContent>
            <Helper>
                <RadzenRequiredValidator Component="Nome"
                                         Text="Nome é obrigatório" />
            </Helper>
        </RadzenFormField>

        <RadzenFormField Text="Descrição" Variant="Variant.Outlined">
            <ChildContent>
                <RadzenTextArea @bind-Value="model.Descricao" Rows="3" />
            </ChildContent>
        </RadzenFormField>

        <RadzenRow Gap="1rem">
            <RadzenColumn Size="6">
                <RadzenFormField Text="Preço (R$)" Variant="Variant.Outlined"
                                Style="width: 100%;">
                    <ChildContent>
                        <RadzenNumeric TValue="decimal" @bind-Value="model.Preco"
                                       Min="0.01m" Format="N2" />
                    </ChildContent>
                    <Helper>
                        <RadzenNumericRangeValidator Component="Preco" Min="0.01"
                                                     Text="Preço deve ser maior que zero" />
                    </Helper>
                </RadzenFormField>
            </RadzenColumn>
            <RadzenColumn Size="6">
                <RadzenFormField Text="Estoque" Variant="Variant.Outlined"
                                Style="width: 100%;">
                    <ChildContent>
                        <RadzenNumeric TValue="int" @bind-Value="model.QuantidadeEstoque"
                                       Min="0" />
                    </ChildContent>
                </RadzenFormField>
            </RadzenColumn>
        </RadzenRow>

        <RadzenFormField Text="Categoria" Variant="Variant.Outlined">
            <ChildContent>
                <RadzenDropDown TValue="int" @bind-Value="model.CategoriaId"
                                Data="@categorias" TextProperty="Nome"
                                ValueProperty="Id"
                                Placeholder="Selecione uma categoria..."
                                AllowFiltering="true" />
            </ChildContent>
            <Helper>
                <RadzenRequiredValidator Component="CategoriaId"
                                         Text="Selecione uma categoria"
                                         DefaultValue="0" />
            </Helper>
        </RadzenFormField>

        <RadzenStack Orientation="Orientation.Horizontal" AlignItems="AlignItems.Center"
                     Gap="0.5rem">
            <RadzenSwitch @bind-Value="model.Ativo" />
            <RadzenText Text="@(model.Ativo ? "Ativo" : "Inativo")" />
        </RadzenStack>

        <RadzenStack Orientation="Orientation.Horizontal"
                     JustifyContent="JustifyContent.End" Gap="0.5rem">
            <RadzenButton Text="Cancelar" ButtonStyle="ButtonStyle.Light"
                          Click="@(() => DialogService.Close(false))"
                          ButtonType="ButtonType.Button" />
            <RadzenButton Text="Salvar" ButtonStyle="ButtonStyle.Primary"
                          ButtonType="ButtonType.Submit" Icon="save" />
        </RadzenStack>
    </RadzenStack>
</RadzenTemplateForm>

Vou destacar os pontos mais importantes do formulário:

  • RadzenTemplateForm<CriarProdutoRequest> — encapsula todo o formulário com validação. O Submit event só é disparado se todos os validators passarem.
  • RadzenRequiredValidator e RadzenNumericRangeValidator — validação client-side com feedback visual automático (bordas vermelhas, mensagem de erro abaixo do campo). A validação server-side via DataAnnotations na API é a segunda camada de proteção.
  • RadzenDropDown<int> para categorias — Data recebe a lista, TextProperty e ValueProperty mapeiam as propriedades do DTO. AllowFiltering="true" habilita busca inline no dropdown (útil quando há muitas categorias).
  • RadzenSwitch com label dinâmico — exibe “Ativo” ou “Inativo” conforme o estado do toggle.
  • Botão Cancelar com ButtonType="ButtonType.Button" — sem isso, o click do Cancelar dispara o submit do formulário.

O @code block contém a lógica de inicialização e submit:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
@code {
    [Parameter] public int? ProdutoId { get; set; }

    [Inject] ProdutoApiService ProdutoService { get; set; } = default!;
    [Inject] CategoriaApiService CategoriaService { get; set; } = default!;
    [Inject] DialogService DialogService { get; set; } = default!;
    [Inject] NotificationService NotificationService { get; set; } = default!;

    CriarProdutoRequest model = new();
    IReadOnlyList<CategoriaDto> categorias = [];

    protected override async Task OnInitializedAsync()
    {
        categorias = await CategoriaService.ListarAsync();

        if (ProdutoId.HasValue)
        {
            var produto = await ProdutoService.ObterPorIdAsync(ProdutoId.Value);
            if (produto is not null)
            {
                model = new CriarProdutoRequest
                {
                    Nome = produto.Nome,
                    Descricao = produto.Descricao,
                    Preco = produto.Preco,
                    QuantidadeEstoque = produto.QuantidadeEstoque,
                    CategoriaId = produto.CategoriaId,
                    Ativo = produto.Ativo
                };
            }
        }
    }

    async Task OnSubmit()
    {
        try
        {
            if (ProdutoId.HasValue)
            {
                var request = new AtualizarProdutoRequest
                {
                    Nome = model.Nome,
                    Descricao = model.Descricao,
                    Preco = model.Preco,
                    QuantidadeEstoque = model.QuantidadeEstoque,
                    CategoriaId = model.CategoriaId,
                    Ativo = model.Ativo
                };
                await ProdutoService.AtualizarAsync(ProdutoId.Value, request);
            }
            else
            {
                await ProdutoService.CriarAsync(model);
            }

            DialogService.Close(true);
        }
        catch (HttpRequestException)
        {
            NotificationService.Notify(new NotificationMessage
            {
                Severity = NotificationSeverity.Error,
                Summary = "Erro ao salvar",
                Detail = "Não foi possível salvar o produto.",
                Duration = 6000
            });
        }
    }
}

O fluxo completo é: usuário clica “Novo Produto” → DialogService.OpenAsync<ProdutoForm>() abre o dialog → o form carrega categorias e, se for edição, carrega o produto → usuário preenche/edita → validators verificam → OnSubmit chama a API → DialogService.Close(true) fecha o dialog → o grid detecta resultado is true e chama grid.Reload() → dados atualizados.

Para abrir o dialog a partir de Produtos.razor:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
async Task AbrirFormulario(int? produtoId)
{
    var titulo = produtoId.HasValue ? "Editar Produto" : "Novo Produto";

    var resultado = await DialogService.OpenAsync<ProdutoForm>(titulo,
        new Dictionary<string, object?> { { "ProdutoId", produtoId } },
        new DialogOptions
        {
            Width = "600px",
            CloseDialogOnOverlayClick = false,
            CloseDialogOnEsc = true
        });

    if (resultado is true)
    {
        await grid.Reload();
    }
}

O dicionário { "ProdutoId", produtoId } passa o parâmetro para o componente ProdutoForm. O DialogOptions controla a largura do dialog e se ele fecha ao clicar fora ou pressionar Escape.

📝 Exemplo: Fluxo de edição — clique no botão “Editar” de um produto → dialog abre com título “Editar Produto” → campos preenchidos com dados atuais → altere o preço → clique Salvar → notificação verde “Produto atualizado” → grid recarrega com preço novo.


Exclusão com Confirmação e Notificações

Toda operação destrutiva deve ter uma etapa de confirmação. O DialogService.Confirm() do Radzen renderiza um dialog nativo com botões customizáveis:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
async Task ConfirmarExclusao(ProdutoDto produto)
{
    var confirmado = await DialogService.Confirm(
        $"Deseja excluir o produto \"{produto.Nome}\"?",
        "Confirmar Exclusão",
        new ConfirmOptions
        {
            OkButtonText = "Excluir",
            CancelButtonText = "Cancelar"
        });

    if (confirmado == true)
    {
        try
        {
            await ProdutoService.RemoverAsync(produto.Id);
            NotificationService.Notify(new NotificationMessage
            {
                Severity = NotificationSeverity.Success,
                Summary = "Produto excluído",
                Detail = $"\"{produto.Nome}\" foi removido com sucesso.",
                Duration = 4000
            });
            await grid.Reload();
        }
        catch (HttpRequestException)
        {
            NotificationService.Notify(new NotificationMessage
            {
                Severity = NotificationSeverity.Error,
                Summary = "Erro ao excluir",
                Detail = "Não foi possível excluir o produto. Tente novamente.",
                Duration = 6000
            });
        }
    }
}

As notificações do Radzen (NotificationService.Notify) aparecem como toasts no canto da tela. Uso NotificationSeverity.Success com duração de 4 segundos para operações bem-sucedidas e NotificationSeverity.Error com 6 segundos para erros — mais tempo para o usuário ler a mensagem. O pattern de try/catch com HttpRequestException é simples mas eficaz: se a API retornar erro (ex: produto não encontrado), o catch exibe feedback imediato ao usuário.


Gerenciamento de Categorias — Inline Editing

Para demonstrar um pattern alternativo ao dialog, a tela de Categorias usa inline editing — o usuário edita diretamente na grid, sem abrir modal. Esse approach funciona bem para entidades simples com poucos campos.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
@page "/categorias"

<RadzenDataGrid @ref="grid" TItem="CategoriaDto"
                Data="@categorias" AllowSorting="true"
                EditMode="DataGridEditMode.Single"
                RowUpdate="@OnRowUpdate" RowCreate="@OnRowCreate"
                Style="width: 100%;">
    <Columns>
        <RadzenDataGridColumn TItem="CategoriaDto" Property="Id" Title="ID"
                              Width="70px" TextAlign="TextAlign.Center" />

        <RadzenDataGridColumn TItem="CategoriaDto" Property="Nome" Title="Nome"
                              MinWidth="200px">
            <EditTemplate Context="cat">
                <RadzenTextBox @bind-Value="cat.Nome" Style="width: 100%;" />
            </EditTemplate>
        </RadzenDataGridColumn>

        <RadzenDataGridColumn TItem="CategoriaDto" Property="Descricao"
                              Title="Descrição" MinWidth="250px">
            <EditTemplate Context="cat">
                <RadzenTextBox @bind-Value="cat.Descricao" Style="width: 100%;" />
            </EditTemplate>
        </RadzenDataGridColumn>

        <RadzenDataGridColumn TItem="CategoriaDto" Property="Ativo" Title="Status"
                              Width="100px" TextAlign="TextAlign.Center">
            <Template Context="cat">
                <RadzenBadge BadgeStyle="@(cat.Ativo ? BadgeStyle.Success : BadgeStyle.Light)"
                             Text="@(cat.Ativo ? "Ativo" : "Inativo")" />
            </Template>
            <EditTemplate Context="cat">
                <RadzenSwitch @bind-Value="cat.Ativo" />
            </EditTemplate>
        </RadzenDataGridColumn>

        <RadzenDataGridColumn TItem="CategoriaDto" Title="Ações" Width="180px"
                              TextAlign="TextAlign.Center" Sortable="false">
            <Template Context="cat">
                <RadzenButton Icon="edit" ButtonStyle="ButtonStyle.Light"
                              Size="ButtonSize.Small"
                              Click="@(() => EditarLinha(cat))" class="rz-mr-1" />
                <RadzenButton Icon="delete" ButtonStyle="ButtonStyle.Danger"
                              Size="ButtonSize.Small"
                              Click="@(() => ConfirmarExclusao(cat))" />
            </Template>
            <EditTemplate Context="cat">
                <RadzenButton Icon="check" ButtonStyle="ButtonStyle.Success"
                              Size="ButtonSize.Small"
                              Click="@(() => SalvarLinha(cat))" class="rz-mr-1" />
                <RadzenButton Icon="close" ButtonStyle="ButtonStyle.Light"
                              Size="ButtonSize.Small"
                              Click="@CancelarEdicao" />
            </EditTemplate>
        </RadzenDataGridColumn>
    </Columns>
</RadzenDataGrid>

A diferença principal está no EditMode="DataGridEditMode.Single": quando o usuário clica em “Editar”, a linha entra em modo de edição — os campos de texto e o switch aparecem no lugar dos valores estáticos. Os botões mudam de “Editar/Excluir” para “Salvar/Cancelar”.

A lógica de edição inline:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
@code {
    RadzenDataGrid<CategoriaDto> grid = default!;
    IList<CategoriaDto> categorias = [];
    CategoriaDto? categoriaEditando;

    async Task InserirNova()
    {
        var nova = new CategoriaDto { Ativo = true };
        categorias.Insert(0, nova);
        categoriaEditando = nova;
        await grid.EditRow(nova);
    }

    async Task EditarLinha(CategoriaDto cat)
    {
        categoriaEditando = cat;
        await grid.EditRow(cat);
    }

    async Task SalvarLinha(CategoriaDto cat)
    {
        await grid.UpdateRow(cat);
    }

    async Task OnRowUpdate(CategoriaDto cat)
    {
        var request = new AtualizarCategoriaRequest
        {
            Nome = cat.Nome,
            Descricao = cat.Descricao,
            Ativo = cat.Ativo
        };
        await CategoriaService.AtualizarAsync(cat.Id, request);
        categoriaEditando = null;
        await CarregarDados();
    }

    async Task OnRowCreate(CategoriaDto cat)
    {
        var request = new CriarCategoriaRequest
        {
            Nome = cat.Nome,
            Descricao = cat.Descricao,
            Ativo = cat.Ativo
        };
        await CategoriaService.CriarAsync(request);
        categoriaEditando = null;
        await CarregarDados();
    }
}

O fluxo para “Nova Categoria” é: inserir um objeto vazio na posição 0 da lista → grid.EditRow() coloca essa linha em modo de edição → usuário preenche → SalvarLinha chama grid.UpdateRow()OnRowCreate é disparado (porque Id == 0) → API chamada → dados recarregados.

Quando usar inline editing vs dialog:

CenárioInline EditingDialog
Entidades simples (2-4 campos)✅ IdealOverkill
Entidades complexas (5+ campos, dropdowns)Confuso✅ Ideal
Campos com validação elaboradaLimitado✅ Mais espaço visual
Edição rápida e frequente✅ Menos cliquesMais cliques
UX mobile⚠️ Pode ficar apertado✅ Melhor em telas pequenas

Dicas e Boas Práticas

  1. Centralize chamadas HTTP em services tipados — nunca injete HttpClient diretamente no componente .razor. Isso viola separação de responsabilidades e dificulta testes. Services como ProdutoApiService encapsulam URLs, serialização e tratamento de erros em um único lugar reutilizável.

  2. Use DataAnnotations + validators Radzen para validação duplaRadzenRequiredValidator e RadzenNumericRangeValidator validam no client; DataAnnotations no DTO de request validam no server. Se alguém bypassar o UI e chamar a API diretamente, a validação server-side ainda protege os dados.

  3. RadzenNotification para TODA ação — feedback visual consistente em sucesso (“Produto criado”) e erro (“Não foi possível salvar”). Defina duração diferente: 4 segundos para sucesso, 6+ para erros (o usuário precisa de mais tempo para ler a mensagem de erro).

  4. LoadData event para paginação server-side — nunca carregue todos os dados no client com uma lista completa. Com LoadData, apenas a página atual é transferida pela rede. Para um catálogo com 10.000 produtos, carregar tudo na memória do browser é inviável; com paginação server-side, cada request traz apenas 20 registros.

  5. Componentize forms reutilizáveisProdutoForm serve para criação E edição. A distinção é um Parameter nullable (int? ProdutoId). Esse pattern elimina duplicação de código e garante consistência entre os fluxos de criação e edição.

  6. Configure appsettings.json para URL da API — nunca faça hardcode de URLs. O wwwroot/appsettings.json no Blazor WASM funciona como arquivo de configuração por ambiente. Em produção, substitua por appsettings.Production.json com a URL real da API.

  7. Implemente loading state no DataGrid — a propriedade IsLoading do RadzenDataGrid exibe um spinner enquanto a API é chamada. Sem feedback visual, o usuário não sabe se a ação foi disparada ou se a aplicação travou. Defina isLoading = true antes da chamada e false depois.

  8. Configure AOT + Trimming para produção — o bundle size do Blazor WASM é uma preocupação real. Para produção, habilite AOT compilation e trimming no .csproj:

    1
    2
    3
    4
    
    <PropertyGroup>
      <RunAOTCompilation>true</RunAOTCompilation>
      <PublishTrimmed>true</PublishTrimmed>
    </PropertyGroup>
    

    AOT compila o IL para WebAssembly nativo (execução mais rápida), e trimming remove código não utilizado (bundle menor). O trade-off é tempo de build significativamente maior.


Conclusão

Neste tutorial, construí um CRUD completo com Blazor WebAssembly .NET 10 e Radzen Blazor: DataGrid com paginação server-side e busca, formulários com validação client-side e server-side, dialogs modais para criação e edição, inline editing para entidades simples, exclusão com confirmação e notificações visuais para feedback — tudo em C#, sem escrever uma linha de JavaScript pelo desenvolvedor.

Radzen Blazor entrega componentes visuais maduros para o cenário CRUD corporativo. O RadzenDataGrid com LoadData resolve paginação server-side de forma elegante. Os validators (RadzenRequiredValidator, RadzenNumericRangeValidator) funcionam bem para validação básica. O DialogService e NotificationService cobrem o fluxo completo de interação com o usuário.

Mas é importante ser transparente sobre as limitações que encontrei durante o desenvolvimento:

  • Radzen.Blazor.js é necessário — apesar do slogan “zero JavaScript”, a biblioteca depende de JS interno para renderizar componentes complexos. Não é JavaScript escrito pelo desenvolvedor, mas é JS rodando no browser.
  • Two-way binding requer classes, não records@bind-Value do Radzen precisa de setters mutáveis. Records com init não funcionam para formulários de edição. Isso força o uso de classes para DTOs no Blazor WASM.
  • O bundle size continua significativo — o runtime do .NET + Radzen + a aplicação resultam em um download inicial considerável. AOT e trimming ajudam, mas não resolvem completamente.

Como analisei no artigo anterior, Blazor WASM é viável para contextos corporativos — e este tutorial demonstra que o ecossistema de componentes suporta cenários reais. A decisão entre Blazor WASM e frameworks JavaScript como Angular ou React depende do perfil da equipe, requisitos de SEO/SSR e tolerância ao bundle size, conforme discuti no comparativo.

Clone o repositório, rode a API e o Blazor WASM, e forme sua própria opinião. O código completo está em frontend/blazor-wasm/ e src/BlogSamples/Produtos/.


Leia Também


Referências