ASP.NET Core MVC または Razor Pages アプリで、ユーザーがコレクションの要素の一部を削除して残りの要素を POST してきた場合に、送信されてきた残りの要素からなるコレクションのモデルバインディングを行うにはどうするかという話を書きます。

先の記事「コレクションのデータアノテーション検証」に書きましたように、コレクションのモデルバインディングがうまく行われるようにするには、レンダリングされる input 要素の name 属性が連番のインデックスを含むようにします。具体的には、name="prefix[index].Property" というパターンとし、index は 0 から始まる連番とします。
この記事の例として上の画像のようなアプリを考えます。ユーザーが[Remove]ボタンをクリックしてある項目を削除してから[Submit]ボタンをクリックすると、残った項目のコレクションが POST されます。
その場合、POST されたコレクションの input 要素の name="prefix[index].Property" 属性の index が連続しなくなってしまいます。
index の数字の連続が途切れた場合、何も対応がされてなければそれ以降の要素はバインドされません。では、バインドされるようにするにはどういう対応を取ったらいいのでしょうか?
Microsoft のドキュメント「ASP.NET Core でのモデル バインド」の Collections のセクションの 5 つ目のコード例にある、selectedCourses.index=a&selectedCourses.index=b というような index を示すデータを一緒にPOST することにより連番でなくてもモデルバインドされるようになります。
例えば、index が 0 の場合、ASP.NET から以下の様な html 要素がレンダリングされるようにコーディングします。
<input type="hidden" name="prefix.Index" value="0" />
<input name="prefix[0].Property" value="ABC" />
参考に上の画像の ASP.NET Core Razor Pages アプリのコードを以下に載せてておきます。Visual Studio 2026 のテンプレートを使ってターゲットフレームワークを .NET 10 として作成したものです。
Model
using System.ComponentModel.DataAnnotations;
namespace RazorPages.Models
{ public class Country
{
[Required(ErrorMessage = "{0} は必須")]
[StringLength(15, ErrorMessage = "{0} は {1} 文字以内")]
[Display(Name = "国名")]
public string Name { get; set; } = string.Empty;
public CountryInfo Details { get; set; } = new();
}
public class CountryInfo
{
[Required(ErrorMessage = "{0} は必須")]
[StringLength(15, ErrorMessage = "{0} は {1} 文字以内")]
[Display(Name = "首都")]
public string Capital { get; set; } = string.Empty;
[Required(ErrorMessage = "{0} は必須")]
[StringLength(15, ErrorMessage = "{0} は {1} 文字以内")]
[Display(Name = "大陸")]
public string Continent { get; set; } = string.Empty;
}
public class ListCountriesViewModel
{
public List<Country> CountryList { get; set; } = new();
public List<Country> SelectedCountries { get; set; } = new();
}
}
CountryList.cshtml.cs
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.RazorPages;
using RazorPages.Models;
namespace RazorPages.Pages
{
public class CountryListModel : PageModel
{
[BindProperty]
public ListCountriesViewModel List { get; set; } = new();
public void OnGet()
{
List = new ListCountriesViewModel
{
CountryList = GetDefaultCountries()
};
}
public IActionResult OnPost()
{
if (ModelState.IsValid)
{
var posted = List.CountryList;
List = new ListCountriesViewModel
{
CountryList = posted,
SelectedCountries = posted
};
// POST された値は ModelState ディクショナリ(model ではない)
// に格納されていて、Tag Helper はまず ModelState ディクショ
// ナリを調べ、そこに値があればそれを表示する仕組みになっている。
// なので、posted の値の通り表示するには ModelState をクリア
// する必要がある
ModelState.Clear();
}
else
{
List = new ListCountriesViewModel
{
CountryList = GetDefaultCountries()
};
}
return Page();
}
private static List<Country> GetDefaultCountries()
{
var countries = new List<Country>
{
new Country()
{
Name = "Italy",
Details = new CountryInfo
{
Capital = "Rome",
Continent = "Europe"
}
},
new Country()
{
Name = "Spain",
Details = new CountryInfo
{
Capital = "Madrid",
Continent = "Europe"
}
},
new Country()
{
Name = "USA",
Details = new CountryInfo
{
Capital = "Washington",
Continent = "NorthAmerica"
}
},
new Country()
{
Name = "Japan",
Details = new CountryInfo
{
Capital = "Tokyo",
Continent = "Asia"
}
},
new Country()
{
Name = "Australia",
Details = new CountryInfo
{
Capital = "Canberra",
Continent = "Oceania"
}
}
};
return countries;
}
}
}
CountryList.cshtml
@page
@model RazorPages.Pages.CountryListModel
<h4>Remove countries not to submit</h4>
<form method="post">
<table class="table">
<thead>
<tr>
<th>
<label asp-for="List.CountryList[0].Name"></label>
</th>
<th>
<label asp-for="List.CountryList[0].Details.Capital"></label>
</th>
<th>
<label asp-for="List.CountryList[0].Details.Continent"></label>
</th>
<th></th>
</tr>
</thead>
<tbody>
@for (int i = 0; i < Model.List.CountryList.Count; i++)
{
<tr class="country-row">
<input type="hidden" name="List.CountryList.Index" value="@i" />
<td>
<input asp-for="List.CountryList[i].Name" class="form-control" />
<span asp-validation-for="List.CountryList[i].Name"
class="text-danger"></span>
</td>
<td>
<input asp-for="List.CountryList[i].Details.Capital"
class="form-control" />
<span asp-validation-for="List.CountryList[i].Details.Capital"
class="text-danger"></span>
</td>
<td>
<input asp-for="List.CountryList[i].Details.Continent"
class="form-control" />
<span asp-validation-for="List.CountryList[i].Details.Continent"
class="text-danger"></span>
</td>
<td>
<button type="button"
class="btn btn-danger"
onclick="this.closest('.country-row').remove();">
Remove
</button>
</td>
</tr>
}
</tbody>
</table>
<input type="submit" value="Submit" class="btn btn-primary" />
</form>
<hr />
<h4>Countries submitted</h4>
<ul>
@if (Model.List.SelectedCountries != null &&
Model.List.SelectedCountries.Count > 0)
{
foreach (var country in Model.List.SelectedCountries)
{
<li>@country.Name</li>
}
}
else
{
<li>No countries submitted.</li>
}
</ul>
@section Scripts {
@{
await Html.RenderPartialAsync("_ValidationScriptsPartial");
}
}