BootstrapBlazor: Validation Error Inside a Table Always Appears on the First Row
Summary
When building Blazor forms with dynamic lists, BootstrapBlazor developers often encounter a baffling issue: validation errors triggered by individual rows erroneously manifest only on the first row of the table. Our investigation reveals this stems from Blazor’s EditContext misassociating validation messages when binding a form to a parent model containing a List<T>. Key takeaways:
✅ Validation logic executes correctly but UI feedback attaches exclusively to the first row
✅ Deleting the first row breaks validation entirely
✅ Core issue: Incorrect model-binding scoping in ValidateForm
Root Cause
Blazor’s ValidateForm creates a single EditContext for its designated Model. When this model contains a collection (e.g., List<PeticionListadoPagosMasivosCuentaPagarProveedor>):
- Single Validation Context: The
EditContexttracks the parent model (PeticionPagosMasivos), not individual row instances. - Overidentified Fields: All rows share identical field names (e.g.,
ValorPago), confusing the validator about which instance owns the error. - First Row Fallback: When validation fails, the framework defaults to showing errors on the first occurrence of
ValorPagodue to ambiguous field mappings.
Deleting the first row exacerbates the problem by destroying the anchor point for validation messages.
Why This Happens in Real Systems
This anti-pattern emerges from two common development paradigms:
- Aggregate Root Binding: Binding
<ValidateForm>to an aggregate root object (e.g.,PeticionPagosMasivos) rather than iterating child models. - Flat Field Semantics: Blazor’s default validation lacks awareness of structural collections, treating each
ValorPagoas the same field. - Stateful Ambiguity: The
EditContextrelies on object references for field identification, but dynamically-generated lists reuse component instances.
The illusion becomes obvious when:
- Validating row #3 highlights row #1
- Deleting row #1 disables all validation
Example or Code
Observe the faulty structure below. Note the ValidateForm bound to PeticionPagosMasivos while <Table> iterates its child collection:
<ValidateForm Model="@PeticionPagosMasivos" ...>
<Table Items="@PeticionPagosMasivos.PagosCuentasPagarProveedor">
<TableColumns>
<!-- Columns -->
<TableColumn ...>
<Template Context="value">
<!-- Input bound to CURRENT ROW -->
<BootstrapInput @bind-Value="value.Row.ValorPago"
RequiredErrorMessage="Required" />
</Template>
</TableColumn>
</TableColumns>
</Table>
</ValidateForm>
Meanwhile, the validator correctly validates individual rows:
public class MaxValorPagoAttribute : ValidationAttribute
{
protected override ValidationResult? IsValid(object? value, ValidationContext ctx)
{
// Correctly accesses ROW INSTANCE (e.g., row #3)
if (ctx.ObjectInstance is PeticionListadoPagosMasivosCuentaPagarProveedor row)
{
if (row.SaldoPendiente.HasValue &&
(decimal)value! > row.SaldoPendiente.Value)
{
return new ValidationResult("Error in row #" + row.CuentaPagarProveedorId);
}
}
return ValidationResult.Success;
}
}
Despite precise error detection, UI feedback malfunctions:
🛑 Error in row #3 → Visual indicator appears on row #1
💥 Deleting row #1 → Validation silently breaks
Real-World Impact
This bug cascades into critical UX and data issues:
- User Confusion: Misattributed errors cause users to edit correct rows instead of faulty ones
- Data Corruption: Submission of invalid rows when errors disappear after deleting row #1
- Testing Blindspots:
- QA may pass validation believing errors appear correctly
- Unit tests miss UI/validation decoupling
- Systemic Failure: Critical payments (like financial transactions shown) process invalid amounts
How Senior Engineers Fix It
Key fix: Decouple validation context per row. Two battle-tested approaches:
1. Row-Level EditForm
Wrap each row in its own EditForm with a row-specific model:
<Table Items="@Items">
<TableColumns>
<TableColumn>
<Template Context="row">
<!-- Create form PER ROW -->
<EditForm Model="@row.Value">
<DataAnnotationsValidator />
<BootstrapInput @bind-Value="row.Value.ValorPago" />
<ValidationMessage For="@(() => row.Value.ValorPago)" />
</EditForm>
</Template>
</TableColumn>
</TableColumns>
</Table>
2. Custom Validation Message Registry
For aggregated form submission, manually register fields:
// In form initialization
var editContext = new EditContext(ParentModel);
foreach (var item in ParentModel.Items)
{
editContext.EnableField(item.ValorPagoFieldIdentifier);
}
Supplemental Fixes:
- Replace
[MaxValorPago]with IValidatableObject for cross-field row validation - Use FluentValidation with child validators for complex collections
Why Juniors Miss It
Juniors overlook this due to:
| Misconception | Reality |
|---|---|
| “Components auto-handle children” | Blazor requires explicit context per child |
| “Table = One Model” | Tables require row-level validation identities |
| Validation = Logic Only | UI binding relies on component lifecycle |
Most critically: Debugging bias. Developers focus on:
🔍 Custom attribute logic (which works)
🔍 Table column bindings (which “work”)
While missing the structural relationships between ValidateForm and nested iterated content.
Senior Insight: Always ask “Where does EditContext think MY FIELD lives?” when validating dynamically-bound collections.