The Problem With Raw Inputs Inside EditForm
Every non-trivial Blazor form eventually needs an input that doesn't exist in the standard library — a tag picker, a rich-text editor, a star rating control, a multi-select with autocomplete. The instinct is to reach for a raw <input> element wired to an @oninput handler and manually push values into the model. That works for a prototype. It breaks down the moment you need the input to participate in EditForm validation: the field doesn't highlight on error, ValidationMessage has nothing to bind to, the submit button doesn't know the field is invalid, and every form you build duplicates the same wiring by hand.
Blazor's answer is InputBase<T> — the abstract base class that all first-party form inputs (InputText, InputNumber<T>, InputDate<T>, InputCheckbox) inherit from. Inheriting it gives you the complete @bind-Value contract, automatic EditContext registration, validation CSS class management, and ValidationMessage compatibility — for free, for any type T you choose. This article walks through exactly how to do it, what each piece does, and where the common mistakes live.
Understanding the Bind Contract: Value, ValueChanged & ValueExpression
Before writing any component code, it is worth understanding what Blazor's compiler actually does with @bind-Value="model.Tags". It is syntactic sugar that expands into three separate parameter assignments. Each parameter serves a distinct role and all three must be present for the binding to work correctly inside EditForm.
Value carries the current value from the parent into the component — a one-directional downward flow. ValueChanged is an EventCallback<T> that the component invokes to push a new value back up to the parent — the upward flow that completes the two-way circuit. ValueExpression is an Expression<Func<T>> that captures a reference to the bound property on the model — not its value, but its identity — so that EditContext can construct a FieldIdentifier and know which validation messages belong to this field. InputBase<T> declares all three and wires them together in its CurrentValue property, so inheriting subclasses get the full contract without redeclaring any of it.
@* ── What the Blazor compiler sees when you write: ───────────────────────
<MyTagInput @bind-Value="model.Tags" />
It expands this into three explicit parameter assignments: *@
<MyTagInput
Value="model.Tags"
ValueChanged="@((List<string> tags) => model.Tags = tags)"
ValueExpression="@(() => model.Tags)" />
@* Value → current value flowing INTO the component (parent → child)
ValueChanged → EventCallback<T> the component fires when value changes
(child → parent). Blazor calls StateHasChanged on the
parent automatically after the callback completes.
ValueExpression → Expression<Func<T>> pointing to the bound property.
Used by EditContext to build a FieldIdentifier.
This is how ValidationMessage<T> finds the right field.
You never write these three parameters manually when you use @bind-Value.
You DO need to declare all three on any component that supports @bind-Value.
InputBase<T> declares them for you — that is the primary reason to inherit it.
CurrentValue in InputBase<T> is the correct setter to use inside your component:
it calls ValueChanged.InvokeAsync and notifies EditContext simultaneously. *@
@* ── The wrong way — raw input without InputBase<T> ────────────────────────
Common pattern that breaks validation integration entirely: *@
@* WRONG: This does NOT participate in EditForm validation.
ValidationMessage, field highlighting, submit guard — all broken.
<input type="text"
value="@Value"
@oninput="e => ValueChanged.InvokeAsync(e.Value?.ToString())" /> *@
@* ── The right way — inherit InputBase<T>, use CurrentValue ──────────────
See the next section for the complete implementation. *@
The distinction between ValueChanged and ValueExpression confuses almost every developer the first time they encounter it. A useful mental model: ValueChanged is about the value (what changed), and ValueExpression is about the field (which property changed). EditContext needs to know the field identity — not just the new value — to route validation errors and CSS state to the correct component on the page.
Consuming the Component Inside EditForm
From the consuming form's perspective, a custom InputBase<T> component is indistinguishable from InputText or any other first-party Blazor input. You bind with @bind-Value, place a ValidationMessage below it, and add your validation attributes to the model property. No extra wiring, no manual EditContext calls, no workarounds.
@* ── Form model with DataAnnotations validation ───────────────────────── *@
@code {
public sealed class CreatePostModel
{
[Required]
[StringLength(120, MinimumLength = 5)]
public string Title { get; set; } = "";
[Required(ErrorMessage = "Add at least one tag.")]
[MinLength(1, ErrorMessage = "Add at least one tag.")]
[MaxLength(5, ErrorMessage = "Maximum 5 tags allowed.")]
public List<string> Tags { get; set; } = [];
}
private readonly CreatePostModel _model = new();
private bool _submitted;
private async Task HandleValidSubmit()
{
// Only reached when ALL fields pass validation —
// including the custom TagInput component.
_submitted = true;
await Task.CompletedTask;
}
}
@* ── The form markup ─────────────────────────────────────────────────────── *@
<EditForm Model="_model" OnValidSubmit="HandleValidSubmit">
<DataAnnotationsValidator />
@if (_submitted)
{
<p class="success-message">Post created successfully.</p>
}
@* Standard InputText — exactly the same pattern as the custom component *@
<div class="form-group">
<label for="title">Title</label>
<InputText id="title"
class="form-input"
@bind-Value="_model.Title"
placeholder="Enter a post title" />
<ValidationMessage For="@(() => _model.Title)" />
</div>
@* Custom TagInput — identical consumption pattern to InputText *@
<div class="form-group">
<label>Tags</label>
<TagInput @bind-Value="_model.Tags"
Placeholder="Type a tag and press Enter"
MaxTags="5"
MaxLength="20" />
<ValidationMessage For="@(() => _model.Tags)" />
@* ValidationMessage works because TagInput inherits InputBase<T>,
which registers the FieldIdentifier with EditContext on initialisation.
No manual wiring needed — the base class handles it. *@
</div>
<button type="submit" class="btn-primary">
Create Post
</button>
@* ValidationSummary lists all errors across all fields.
Works for custom components identically to standard inputs. *@
<ValidationSummary />
</EditForm>
@* ── What EditForm enforces automatically ────────────────────────────────
OnValidSubmit is only called when ALL validators pass — including
the [Required] and [MinLength] on List<string> Tags.
OnInvalidSubmit fires instead if any field is invalid.
The submit button does not need disabled logic — EditForm handles it.
The full validation lifecycle for the custom component:
1. User adds/removes tags → TagInput sets CurrentValue
2. CurrentValue setter calls EditContext.NotifyFieldChanged(FieldIdentifier)
3. EditContext runs DataAnnotations validators for the Tags field
4. FieldCssClass on TagInput updates to "modified valid" or "modified invalid"
5. ValidationMessage For="@(() => _model.Tags)" displays or clears its message *@
Notice that consuming the custom TagInput is byte-for-byte identical to consuming InputText — same @bind-Value, same ValidationMessage, same DataAnnotations on the model. That is the correct measure of a well-built Blazor component: a developer who has never seen its implementation can consume it correctly on first contact, because it follows the same contract as every other input in the framework.
Three Mistakes That Break Your Component
The implementation pattern is straightforward once you understand it, but three specific mistakes appear in almost every custom Blazor input component written without prior knowledge of InputBase<T>. Each one causes a symptom that is difficult to diagnose without knowing what to look for.
// ── MISTAKE 1: Mutating CurrentValue in place (reference types) ───────────
//
// Symptom: tags appear to add/remove in the UI but the parent model
// never updates, validation never runs, form submit sends stale data.
//
// WRONG:
private void AddTagWrong(string tag)
{
CurrentValue ??= [];
CurrentValue.Add(tag); // mutates in place — reference unchanged
// ValueChanged is NEVER called because InputBase<T> sees the same reference
// EditContext.NotifyFieldChanged is NEVER called — validation silently skips
}
// CORRECT: always assign a new instance
private void AddTagCorrect(string tag)
{
var updated = [.. (CurrentValue ?? []), tag]; // new list, new reference
CurrentValue = updated; // triggers ValueChanged + NotifyFieldChanged
}
// ── MISTAKE 2: Calling StateHasChanged() after setting CurrentValue ────────
//
// Symptom: double re-render on every change, subtle UI flicker,
// in Blazor Server apps can cause SignalR message storms
// under high interaction frequency.
//
// WRONG:
private void HandleChangeWrong(ChangeEventArgs e)
{
CurrentValue = e.Value?.ToString();
StateHasChanged(); // redundant — CurrentValue setter already calls this
// via ValueChanged callback completion
}
// CORRECT: set CurrentValue and stop. The framework handles the re-render.
private void HandleChangeCorrect(ChangeEventArgs e)
{
CurrentValue = e.Value?.ToString();
}
// ── MISTAKE 3: Assuming EditContext is always present ─────────────────────
//
// Symptom: NullReferenceException when the component is used outside EditForm,
// for example in a standalone widget, a dialog, or a test harness.
//
// InputBase<T> exposes the cascading EditContext as a protected property.
// It is null when no EditForm ancestor exists. Do not access it directly
// in your component logic — write through CurrentValue instead and let
// InputBase<T> handle the null check internally.
//
// WRONG:
protected override void OnInitialized()
{
EditContext!.OnValidationStateChanged += (_, _) => StateHasChanged();
// NullReferenceException when used outside EditForm
}
// CORRECT: guard before accessing, or avoid accessing EditContext directly
protected override void OnInitialized()
{
if (EditContext is not null)
{
EditContext.OnValidationStateChanged += (_, _) => StateHasChanged();
}
}
// ── MISTAKE 4 (bonus): Initialising CurrentValue with a new list default ──
//
// Symptom: every instance of the component starts with an independent empty
// list, so the parent model's property and the component's internal
// value are different references from the first render — mutations
// in the component never reflect in the model.
//
// WRONG: do not set CurrentValue in OnInitialized or in a parameter default
// private List<string> _tags = []; // and then using this internally
//
// CORRECT: always read from and write to CurrentValue. Never maintain a
// parallel internal copy of the value — CurrentValue IS the value.
Mistake 1 — mutating a reference-type CurrentValue in place — is the most common and the hardest to diagnose because the UI can look correct while the model is silently wrong. The component re-renders (Blazor re-renders on any event by default), the visual state updates, and the bug only surfaces at form submission when the server receives the original empty list. Always create a new collection instance. Always assign through CurrentValue. These two rules eliminate the entire category.