Previously, in Part 6, we gave PatientCard.razor a Patient parameter and an OnSelected event so a card could talk back to its page. Today ClinicApp grows up a little: instead of hard-coding patients into a list, you will add them through a real form — one that politely refuses bad data.

Why the hard-coded patients have to go

Since Part 5, our Patients.razor page (the one answering at /patients) has carried a List<Patient> that we typed by hand. That was perfect for learning, but think about a real clinic for a second. Does the receptionist recompile the app every time a new patient walks in? Of course not. They fill in a form.

Blazor gives us a small team of built-in components for exactly this job: EditForm wraps the form, a validator watches the data, and validation messages report problems to the user. We'll meet each one in a moment. First, though, our Patient model needs to learn some rules.

Step 1: give Patient some rules

How does Blazor know that a patient without a name is a mistake, but an age of 45 is fine? You tell it — right on the model, using data annotations. These are little attributes you attach to properties, like sticky notes that say "this field must follow this rule." Open your Patient class and add two of them:

using System.ComponentModel.DataAnnotations;

public class Patient
{
    public int Id { get; set; }

    [Required(ErrorMessage = "Every patient needs a name.")]
    public string Name { get; set; } = "";

    [Range(0, 120, ErrorMessage = "Age must be between 0 and 120.")]
    public int Age { get; set; }
}

[Required] says Name cannot be left empty, and [Range(0, 120)] keeps Age inside believable human limits. Both attributes live in the System.ComponentModel.DataAnnotations namespace — that's what the using line at the top is for. (If you ever need these rules inside a .razor file instead, the same line is written @using System.ComponentModel.DataAnnotations.)

Notice the custom ErrorMessage on each rule. You could leave it out and get a default message, but writing your own keeps the app friendly — and you'll see exactly where these messages show up next.

Step 2: build the form on Patients.razor

Now for the form itself. Add this above your patient list in Patients.razor:

@page "/patients"
@rendermode InteractiveServer

<EditForm Model="newPatient" OnValidSubmit="AddPatient">
    <DataAnnotationsValidator />

    <label>
        Name:
        <InputText @bind-Value="newPatient.Name" />
    </label>
    <ValidationMessage For="() => newPatient.Name" />

    <label>
        Age:
        <InputNumber @bind-Value="newPatient.Age" />
    </label>
    <ValidationMessage For="() => newPatient.Age" />

    <button type="submit">Add patient</button>
</EditForm>

(That @rendermode InteractiveServer line under @page has been with us since Part 5 — it keeps the page interactive so clicks and keystrokes actually reach your code. Part 12 explains what it really does; for now, just make sure it's there.)

The trio: who does what

There's a lot of new markup up there, but it boils down to three characters, each with one job:

  • EditForm wraps. It replaces the plain HTML <form> tag, and its Model attribute tells Blazor which object this form is editing — our newPatient.
  • DataAnnotationsValidator watches. It reads the sticky notes we put on Patient and checks the form's values against them. No validator, no validation — forget this line and every submit sails through.
  • ValidationMessage reports. Each one displays the error for a single field, chosen by its For expression. Put it next to the input it describes, so the user never has to hunt for what went wrong.

The inputs themselves — InputText and InputNumber — are Blazor's form-aware cousins of <input>. Their @bind-Value attribute keeps the box on screen and the property on newPatient in sync, in both directions, automatically.

Step 3: handle a valid submit

When the user clicks the button and every rule passes, EditForm calls whatever method you named in OnValidSubmit. Ours is AddPatient, and it lives in the page's @code block:

@code {
    private Patient newPatient = new();

    private void AddPatient()
    {
        newPatient.Id = patients.Count == 0 ? 1 : patients.Max(p => p.Id) + 1;
        patients.Add(newPatient);
        newPatient = new Patient();
    }
}

Three small moves. First, we assign a new Id — one higher than the biggest one in the list, or 1 if the list is empty. Second, we add the patient to patients, the same List<Patient> our live search from Part 5 already reads from, so the new patient appears instantly. Third, we point newPatient at a fresh Patient object — and because the inputs are bound to it, the form clears itself, ready for the next entry.

Tip: OnValidSubmit only fires when every rule passes. Try it: leave the form empty and hit the button. Nothing is added — instead, your error messages appear right where you placed the ValidationMessage components. That's the whole trio working together in one click.

Where did these rules come from?

One last thing worth noticing: we didn't invent Name and Age on a whim. We decided these attributes deliberately, back in the design series, when we worked out what a clinic actually needs to store about a patient. Design decisions on paper became C# properties, and today those properties grew validation rules. That's the thread connecting both series: good systems are designed first and coded second.

Up next

Our clinic can finally take on new patients without a recompile. But a patient is more than a name and an age — a real details view needs room for an address, history, and more, and that deserves its own page with its own URL. How does a URL like /patients/2 find its way to the right patient? That's routing, and it's exactly where we're headed in Part 8.