Lista dinâmica no ASP.NET Core: índice e Razor
Linhas que o usuário adiciona e remove no ASP.NET Core: por que índice com buraco some no model binding e como o campo Index com chave própria resolve.
Tela de pedido com linhas que o usuário adiciona e remove é onde o model binding do ASP.NET Core mais surpreende. O C# está certo, o EF Core está certo, e mesmo assim a lista chega ao servidor sem metade dos itens. Quase sempre o culpado é o HTML: os nomes dos campos que o JavaScript montou depois que a página carregou.
Este post fica no lado do navegador. Primeiro, um teste com curl que mostra quais nomes o binder aceita. Depois, uma página Razor com um <template> e um JavaScript curto que adiciona e remove linhas sem furar a lista. Tudo foi testado com .NET 10 e Chrome em modo headless. O que o servidor faz com a lista recebida (atualizar, inserir, apagar e recusar Id de outro pedido) está no guia para salvar a lista editável com EF Core.
Seis formulários, seis resultados
Para enxergar o que o binder monta, uma action de teste devolve a lista que recebeu, item por item, no formato Id:Produto:Quantidade:
public class ItemForm
{
public int Id { get; set; }
public string Produto { get; set; } = "";
public int Quantidade { get; set; }
public bool Remover { get; set; }
}
public class PedidoForm { public List<ItemForm> Itens { get; set; } = new(); }
[HttpPost("/eco")]
public IActionResult Eco([FromForm] PedidoForm form) =>
Ok(form.Itens.Select(i => $"{i.Id}:{i.Produto}:{i.Quantidade}"));
Com a aplicação rodando em localhost, cada caso é um curl -d com os campos do formulário:
eco "0, 1, 2" -d 'Itens[0].Produto=Parafuso' -d 'Itens[1].Produto=Bucha' -d 'Itens[2].Produto=Broca' eco "0 e 2 (linha do meio removida)" -d 'Itens[0].Produto=Parafuso' -d 'Itens[2].Produto=Broca' eco "começa em 1" -d 'Itens[1].Produto=Bucha' -d 'Itens[2].Produto=Broca' eco "nome sem índice" -d 'Itens.Produto=Parafuso' -d 'Itens.Produto=Bucha' eco "Index com chaves livres" -d 'Itens.Index=a' -d 'Itens[a].Produto=Parafuso' -d 'Itens.Index=k7f2' -d 'Itens[k7f2].Produto=Arruela' eco "chave no Index sem campos" -d 'Itens.Index=a' -d 'Itens[a].Produto=Parafuso' -d 'Itens.Index=b'
O resultado, com o status HTTP na frente:
# 0, 1, 2 200 ["0:Parafuso:0","0:Bucha:0","0:Broca:0"] # 0 e 2 (linha do meio removida) 200 ["0:Parafuso:0"] # começa em 1 200 [] # nome sem índice 200 [] # Index com chaves livres 200 ["0:Parafuso:0","0:Arruela:0"] # chave no Index sem campos 500 System.NullReferenceException: Object reference not set to an instance of an object. at
Os três primeiros casos seguem a regra que a documentação de model binding da Microsoft deixa escrita: índices numéricos precisam começar em zero e ser sequenciais, e tudo o que vem depois de um buraco é ignorado. Remover a linha do meio no DOM, com Itens[0] e Itens[2] sobrando, faz a broca sumir. Uma lista que começa em 1 (comum quando o índice vem de um contador que começa no número da linha) chega vazia, sem erro e sem aviso.
O quarto caso pega quem escreve name="Itens.Produto" num foreach sem índice. Esse formato repetido funciona para lista de tipo simples, como int[], mas não para lista de objetos: o binder não tem como saber qual Produto combina com qual Quantidade.
O quinto é a saída: o campo Itens.Index. Cada linha declara a própria chave, que pode ser qualquer texto, e o binder junta os campos Itens[chave].* na ordem em que as chaves aparecem. Sem sequência, sem buraco possível.
O sexto mostra o efeito colateral: uma chave declarada em Itens.Index sem nenhum campo correspondente vira um item null dentro da lista, e o primeiro i.Id estoura NullReferenceException. Acontece quando o JavaScript remove os inputs de uma linha mas esquece o input hidden da chave.
A página: cada linha carrega a própria chave
Na página Razor, a chave de uma linha que já existe no banco sai do Id (i1, i2...). Assim ela é estável entre um carregamento e outro:
<form id="pedido" method="post" action="/pedidos/@Model.Pedido.Id">
<table>
<tbody id="linhas">
@foreach (var item in Model.Pedido.Itens)
{
var k = $"i{item.Id}";
<tr>
<td>
<input type="hidden" name="Itens.Index" value="@k">
<input type="hidden" name="Itens[@k].Id" value="@item.Id">
<input name="Itens[@k].Produto" value="@item.Produto">
</td>
<td><input type="number" name="Itens[@k].Quantidade" value="@item.Quantidade"></td>
<td>
<input type="hidden" name="Itens[@k].Remover" value="false">
<button type="button" data-remover>Remover</button>
</td>
</tr>
}
</tbody>
</table>
<button type="button" id="adicionar">Adicionar item</button>
<button type="submit">Salvar</button>
</form>
Duas escolhas importam aqui. O botão de remover é type="button", senão ele envia o formulário. E cada linha tem um hidden Remover com false: em item que já está no banco, apagar a linha do DOM não diz nada ao servidor, que não tem como diferenciar "a pessoa removeu" de "o campo se perdeu no caminho". Marcar Remover=true é um pedido explícito.
A linha nova vem de um <template>, com __k__ no lugar da chave e Id 0:
<template id="modelo-linha">
<tr>
<td>
<input type="hidden" name="Itens.Index" value="__k__">
<input type="hidden" name="Itens[__k__].Id" value="0">
<input name="Itens[__k__].Produto">
</td>
<td><input type="number" name="Itens[__k__].Quantidade" value="1"></td>
<td>
<input type="hidden" name="Itens[__k__].Remover" value="false">
<button type="button" data-remover>Remover</button>
</td>
</tr>
</template>
O JavaScript: adicionar troca a chave, remover depende da origem
<script>
const linhas = document.getElementById("linhas");
const modelo = document.getElementById("modelo-linha");
document.getElementById("adicionar").addEventListener("click", () => {
const k = "n" + crypto.randomUUID().slice(0, 8);
linhas.insertAdjacentHTML("beforeend", modelo.innerHTML.replaceAll("__k__", k));
});
linhas.addEventListener("click", (e) => {
const botao = e.target.closest("[data-remover]");
if (!botao) return;
const linha = botao.closest("tr");
if (linha.querySelector('[name$=".Id"]').value === "0") {
linha.remove(); // linha nova: some com chave e tudo
} else {
linha.querySelector('[name$=".Remover"]').value = "true";
linha.hidden = true; // item do banco: vai com Remover=true
}
});
</script>
Adicionar copia o template trocando __k__ por uma chave aleatória. crypto.randomUUID() só existe em contexto seguro (HTTPS ou localhost), o que cobre produção e desenvolvimento. Remover tem dois comportamentos. Linha nova sai inteira do DOM, com o hidden de Itens.Index junto, e isso evita o item null do sexto caso. Linha que veio do banco fica escondida com Remover=true.
O teste no navegador
Para conferir o conjunto, um script de teste carregado só com ?autoteste na URL clica em "Remover" na Bucha, adiciona a Arruela com 20 unidades, adiciona outra linha e a remove antes de salvar, muda a Broca para 2 e envia o formulário duas vezes: para a action de eco e para a action que salva. Rodando no Chrome headless contra o pedido 1093 (Parafuso, Bucha e Broca 6mm), o formulário saiu com 4 chaves e este corpo (trecho):
Itens.Index=i2 Itens[i2].Id=2 Itens[i2].Produto=Bucha Itens[i2].Quantidade=10 Itens[i2].Remover=true ... Itens.Index=n787da13a Itens[n787da13a].Id=0 Itens[n787da13a].Produto=Arruela Itens[n787da13a].Quantidade=20 Itens[n787da13a].Remover=false ECO ["1:Parafuso:10","2:Bucha:10","3:Broca 6mm:2","0:Arruela:20"] SALVO 200 ["1:Parafuso:10","3:Broca 6mm:2","10:Arruela:20"]
A linha desistida não deixou rastro nenhum, a Bucha chegou marcada para remoção e a Arruela entrou com Id 0, que o servidor transformou no item 10. É isso que se quer ver antes de abrir para o usuário.
Detalhes que quebram em produção
- Não chame nada de
Indexperto da lista. A mesma página da Microsoft avisa que um parâmetro ou propriedade chamadoindexao lado de uma coleção é usado pelo binder como índice da coleção. Uma actionPost(string index, List<Item> itens)mistura as duas coisas. - Linha escondida continua no formulário.
hiddensó esconde. Se você usa validação no cliente comrequired, o navegador pode travar o envio por causa de um campo invisível. Tire orequiredao marcar a remoção. - Vue ou outro framework dentro da página. A regra é a mesma: o framework precisa gerar os nomes
Itens.IndexeItens[chave].Campo. Se a tela já usa Vue, veja como manter a ilha de Vue dentro do Razor sem perder o envio normal do formulário. - Id no hidden não é confiável. O usuário consegue editar o valor e mandar o Id de um item de outro pedido. A defesa fica no servidor e é o assunto do guia de salvamento com EF Core.