BootstrapBlazor: Validation error inside a Table always appears on the first row

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 EditContext tracks 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 ValorPago due 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:

  1. Aggregate Root Binding: Binding <ValidateForm> to an aggregate root object (e.g., PeticionPagosMasivos) rather than iterating child models.
  2. Flat Field Semantics: Blazor’s default validation lacks awareness of structural collections, treating each ValorPago as the same field.
  3. Stateful Ambiguity: The EditContext relies 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.

Leave a Comment