Executando verificação de segurança...
4

Zero JavaScript: CRUD Completo com Blazor WASM e Radzen

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 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" />:

<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:

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.JsonGetFromJsonAsync, 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.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.


📖 Artigo completo com exemplos de código: Zero JavaScript: CRUD Completo com Blazor WASM e Radzen

Carregando publicação patrocinada...