ModelState Patterns — Errors, Display and AJAX Validation

ModelState is the central validation state object in ASP.NET Core MVC. It accumulates validation results from model binding, DataAnnotations, and manual calls to AddModelError(). Knowing how to read, modify, and respond to ModelState — including partial validation, AJAX form submission, and manual error injection — covers every realistic form scenario. These patterns appear throughout the Web API and full-stack integration chapters.

ModelState API

// ── Checking validity ─────────────────────────────────────────────────────
if (!ModelState.IsValid)
    return View(model);   // re-display form with errors

// ── Reading all errors ─────────────────────────────────────────────────────
foreach (var (key, state) in ModelState)
{
    foreach (var error in state.Errors)
    {
        Console.WriteLine($"{key}: {error.ErrorMessage}");
    }
}

// Errors as a dictionary (useful for AJAX responses)
var errors = ModelState
    .Where(ms => ms.Value!.Errors.Any())
    .ToDictionary(
        ms => ms.Key,
        ms => ms.Value!.Errors.Select(e => e.ErrorMessage).ToArray());

// ── Adding errors manually ────────────────────────────────────────────────
ModelState.AddModelError(nameof(model.Slug), "Slug is already taken.");
ModelState.AddModelError(string.Empty, "An unexpected error occurred.");  // model-level

// ── Removing errors selectively ───────────────────────────────────────────
// Remove an irrelevant field's errors (e.g., a field not shown in this form context)
ModelState.Remove(nameof(model.ConfirmPassword));
// Now ModelState.IsValid only considers other fields
if (!ModelState.IsValid) return View(model);

// ── Clearing ModelState for re-population ────────────────────────────────
ModelState.Clear();   // used when you want to re-render a clean form after partial save
Note: ModelState.IsValid is false if any entry has errors — including for properties not shown in the current form. If a ViewModel has a property with [Required] that you intentionally do not include in the form (because it is set server-side), model binding will add a “required” error for it. Use ModelState.Remove(nameof(model.ServerId)) before checking IsValid to exclude server-set properties from the validity check. Alternatively, design separate ViewModels so this situation does not arise.
Tip: For AJAX form submissions, return a JSON response containing validation errors when ModelState.IsValid is false, instead of returning a full HTML view. The client-side JavaScript reads the JSON, displays inline errors, and does not redirect. This pattern gives you the same validation UX as a full page submit but without the page reload. Return a consistent error response shape: { success: false, errors: { FieldName: ["Error message"] } } — the client JavaScript maps field names to the corresponding form inputs.
Warning: Never call ModelState.Clear() to make ModelState.IsValid return true and proceed with invalid data. Clear() exists for legitimate scenarios: after a partial save where you want to reset validation state for a fresh form re-display, or when switching context (editing a sub-form within a larger form). If you find yourself calling Clear() to bypass validation, the correct fix is to fix the validation logic or the ViewModel design.

AJAX Form Submission Pattern

// ── Controller action that handles both regular and AJAX form submissions ──
[HttpPost, ValidateAntiForgeryToken]
public async Task<IActionResult> Create(CreatePostViewModel model)
{
    if (!ModelState.IsValid)
    {
        if (Request.Headers["X-Requested-With"] == "XMLHttpRequest")
        {
            // AJAX request — return JSON with validation errors
            var errors = ModelState
                .Where(ms => ms.Value!.Errors.Any())
                .ToDictionary(
                    ms => ms.Key,
                    ms => ms.Value!.Errors.Select(e => e.ErrorMessage).ToArray());

            return BadRequest(new { success = false, errors });
        }
        return View(model);   // regular form submit — re-display with errors
    }

    // Async business validation
    if (await _service.SlugExistsAsync(model.Slug))
    {
        ModelState.AddModelError(nameof(model.Slug), "Slug already exists.");
        if (Request.IsAjaxRequest())
            return BadRequest(new { success = false, errors = new { model.Slug } });
        return View(model);
    }

    var post = await _service.CreateAsync(model.ToRequest());

    if (Request.IsAjaxRequest())
        return Ok(new { success = true, postId = post.Id, redirectUrl = Url.Action(nameof(Details), new { post.Id }) });

    TempData["Success"] = "Post created!";
    return RedirectToAction(nameof(Index));
}

// ── Extension method for AJAX detection ──────────────────────────────────
public static bool IsAjaxRequest(this HttpRequest request)
    => request.Headers["X-Requested-With"] == "XMLHttpRequest";

Common Mistakes

Mistake 1 — Using ModelState.Clear() to bypass validation errors

❌ Wrong — clears all errors to make IsValid = true; proceeds with invalid data.

✅ Correct — fix validation logic or use ModelState.Remove() for specific intentionally excluded fields.

Mistake 2 — Returning JSON errors without the correct HTTP status code

❌ Wrong — returning 200 OK with error content confuses client-side error handling:

return Ok(new { success = false, errors });   // 200 with errors — misleading!

✅ Correct — return BadRequest(new { errors }) so the client can check HTTP status (400) for failure.

🧠 Test Yourself

A ViewModel has a ConfirmPassword field with [Compare("Password")] but the edit form does not show ConfirmPassword. ModelState.IsValid is always false. How do you fix this?