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:
git clone https://github.com/lzocateli/blog-zocateli-sample.git
cd blog-zocateli-sample
Verifique se o SDK está instalado:
dotnet --version
Output esperado:
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
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:
<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:
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:
@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 noApp.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" />:
<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:
| Verbo | Rota | Descrição |
|---|---|---|
| GET | /api/produtos?pagina=1&tamanhoPagina=20&filtro= | Listar com paginação e filtro |
| GET | /api/produtos/{id} | Obter por ID |
| POST | /api/produtos | Criar produto |
| PUT | /api/produtos/{id} | Atualizar produto |
| DELETE | /api/produtos/{id} | Remover produto |
| GET | /api/categorias | Listar todas |
| GET | /api/categorias/{id} | Obter por ID |
| POST | /api/categorias | Criar 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:
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:
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:
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:
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.Json — GetFromJsonAsync, PostAsJsonAsync e PutAsJsonAsync:
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.EscapeDataStringno filtro previne injeção de parâmetros na query string. Nunca concatene strings diretamente em URLs sem encoding. -
EnsureSuccessStatusCode()lançaHttpRequestExceptionse 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. OHttpClienté resolvido pelo container de DI com aBaseAddressconfigurada noProgram.cs. OCategoriaApiServicesegue exatamente o mesmo pattern, com métodosListarAsync,CriarAsync,AtualizarAsynceRemoverAsync.
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 cominitnã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:
@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. RecebeLoadDataArgscomSkip,Tope informações de ordering. Essa é a chave para paginação server-side. -
Data+Count—Datarecebe a página atual de itens;Countinforma 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. UsoRadzenBadgepara 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@codeblock contém a lógica:
@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
LoadDatadoRadzenDataGridé chamado toda vez que o grid precisa de dados — paging, sorting, filtering. Não confunda comDatabinding direto, que é para dados client-side. Se você usarDatacom uma lista completa eAllowPaging, a paginação será client-side (todos os dados carregados de uma vez). ComLoadData+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>().
<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. OSubmitevent só é disparado se todos os validators passarem. -
RadzenRequiredValidatoreRadzenNumericRangeValidator— 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 —Datarecebe a lista,TextPropertyeValuePropertymapeiam as propriedades do DTO.AllowFiltering="true"habilita busca inline no dropdown (útil quando há muitas categorias). -
RadzenSwitchcom 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@codeblock contém a lógica de inicialização e submit:
@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:
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:
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.
@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:
@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ário | Inline Editing | Dialog |
|---|---|---|
| Entidades simples (2-4 campos) | ✅ Ideal | Overkill |
| Entidades complexas (5+ campos, dropdowns) | Confuso | ✅ Ideal |
| Campos com validação elaborada | Limitado | ✅ Mais espaço visual |
| Edição rápida e frequente | ✅ Menos cliques | Mais cliques |
| UX mobile | ⚠️ Pode ficar apertado | ✅ Melhor em telas pequenas |
Dicas e Boas Práticas
Centralize chamadas HTTP em services tipados — nunca injete
HttpClientdiretamente no componente.razor. Isso viola separação de responsabilidades e dificulta testes. Services comoProdutoApiServiceencapsulam URLs, serialização e tratamento de erros em um único lugar reutilizável.Use DataAnnotations + validators Radzen para validação dupla —
RadzenRequiredValidatoreRadzenNumericRangeValidatorvalidam 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.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).
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.Componentize forms reutilizáveis —
ProdutoFormserve para criação E edição. A distinção é umParameternullable (int? ProdutoId). Esse pattern elimina duplicação de código e garante consistência entre os fluxos de criação e edição.Configure
appsettings.jsonpara URL da API — nunca faça hardcode de URLs. Owwwroot/appsettings.jsonno Blazor WASM funciona como arquivo de configuração por ambiente. Em produção, substitua porappsettings.Production.jsoncom a URL real da API.Implemente loading state no DataGrid — a propriedade
IsLoadingdoRadzenDataGridexibe 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. DefinaisLoading = trueantes da chamada efalsedepois.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:
<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-Valuedo Radzen precisa de setters mutáveis. Records cominitnã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
- Seu Próximo Frontend Será C#? A Verdade Sobre Blazor WASM — comparativo teórico Blazor vs Angular (1º artigo da série “Frontend Moderno”)
- Design de APIs REST: Verbos HTTP e Parameter Binding — a estrutura de Minimal API que o Blazor WASM consome neste tutorial
- Log Sem Contexto é Ruído: Logging Estruturado no .NET 8 — ecossistema .NET maduro com logging, métricas e tracing
- Autenticação e Autorização: JWT, OAuth2 e OpenID Connect — próximo passo: proteger a SPA Blazor com Entra ID
Referências
- Blazor WebAssembly — Documentação oficial — Microsoft Learn, referência completa sobre Blazor
- ASP.NET Core 10.0 Release Notes — novidades do .NET 10 para web
- Radzen Blazor Components — Get Started — setup e guia inicial da biblioteca
- Radzen Blazor — GitHub — código fonte (MIT) com exemplos e issues
- Radzen DataGrid — documentação e demos interativos do componente central
- blog-zocateli-sample — GitHub — repositório com todo o código deste artigo
- WebAssembly — especificação oficial do padrão W3C
- Blazor WASM Standalone Deployment — guia de deploy para produção 📬
👉 Artigo completo com todos os exemplos de código: Zero JavaScript: CRUD Completo com Blazor WASM e Radzen
Top comments (0)