Blazor: EditForm, Validation, Query Parameters, and API Integration
Blazor provides several powerful features for building interactive web applications using C# and Razor syntax. In this article, we will explore how Blazor components can handle user input, validate forms, display dynamic information, and communicate with a backend API.
Before looking at the complete membership subscription page, let's understand a useful Blazor concept with a small demo.
1. Demo: Automatically Updating a Price with Blazor Data Binding
Imagine you are building an online shopping application. A customer selects a product quantity, and the total price should update automatically.
Blazor supports this through data binding using @bind.
Example: Quantity and Total Price
<h3>Product Price Calculator</h3> <p>Price per item: ₹500</p> <label>Quantity:</label> <input type="number" @bind="quantity" min="1" /> <p>Total Price: ₹@(500 * quantity)</p> @code { private int quantity = 1; } How does it work?
-
quantitystores the selected quantity. -
@bind="quantity"connects the input field to the C# property. - When the quantity changes, Blazor updates the bound value.
- The expression
500 * quantitycalculates the total price. - Blazor renders the updated total in the UI.
For example, if the customer selects a quantity of 3, the total price becomes ₹1500.
This is one of the key benefits of Blazor: we can build interactive interfaces using C# without manually updating HTML elements through JavaScript.
In a real application, this same concept can be used to update a membership subscription summary when a user selects a different plan or start date.
2. Understanding the Membership Subscription Page
Now let's apply Blazor concepts to a practical example: a Gym Membership Management System.
The subscription page allows users to select a membership plan, choose a start date, select a payment method, accept terms and conditions, and submit their subscription to a backend API.
The page includes these features:
- Receives the selected gym and membership plan through query-string parameters.
- Loads gym details and membership plans from an API.
- Displays loading, error, and empty states.
- Uses
EditFormfor form submission. - Uses data annotations and validation components.
- Displays a summary of the selected membership plan.
- Calculates an expected membership end date.
- Checks business rules before submitting.
- Sends the subscription request to the backend.
- Displays success and error messages through SweetAlert.
- Prevents repeated submissions while a request is in progress.
The following example demonstrates how these features can be combined in one Razor component.
3. Complete Blazor Membership Subscription Component
The complete original code is included below. It has been kept unchanged.
@page "/subscribe" @using System.ComponentModel.DataAnnotations @using System.Net.Http.Json @inject HttpClient http @inject NavigationManager navigation @inject SweetAlertService Swal <PageTitle>Membership Subscription</PageTitle> <div class="container py-4"> <h3 class="fw-bold mb-1">Membership Subscription</h3> <p class="text-muted mb-4"> Complete the form to subscribe to a membership plan. </p> @if (isLoading) { <div class="text-center p-5"> <div class="spinner-border text-primary"></div> <p class="mt-2">Loading subscription details...</p> </div> } else if (loadError) { <div class="alert alert-danger"> Unable to load the selected gym or its plans. <button class="btn btn-outline-danger btn-sm ms-2" @onclick="LoadFormData"> Retry </button> </div> } else { <div class="row justify-content-center"> <div class="col-12 col-lg-8"> <div class="card shadow-sm"> <div class="card-header bg-white"> <h5 class="mb-0">Subscription Details</h5> </div> <div class="card-body p-4"> <EditForm Model="@formModel" > <DataAnnotationsValidator /> <ValidationSummary class="text-danger" /> <!-- Preselected Gym --> <div class="mb-3"> <label class="form-label fw-semibold"> Gym </label> <input class="form-control" value="@selectedGymName" readonly /> <div class="form-text"> Your selected gym cannot be changed here. </div> </div> <!-- Required Membership Plan Dropdown --> <div class="mb-3"> <label class="form-label fw-semibold"> Membership Plan <span class="text-danger">*</span> </label> <InputSelect class="form-select" @bind-Value="formModel.MembershipPlanId"> <option value="0"> -- Select Membership Plan -- </option> @foreach (var plan in planList) { <option value="@plan.Id" disabled="@(!IsActive(plan.Status))"> @plan.PlanName - ₹@plan.Price.ToString("N2") (@plan.DurationInMonths months) </option> } </InputSelect> <ValidationMessage For="@(() => formModel.MembershipPlanId)" class="text-danger" /> </div> <!-- Selected Plan Details --> @if (SelectedPlan is not null) { <div class="alert alert-light border"> <h6 class="fw-bold"> @SelectedPlan.PlanName </h6> <p class="mb-1"> <strong>Price:</strong> ₹@SelectedPlan.Price.ToString("N2") </p> <p class="mb-1"> <strong>Duration:</strong> @SelectedPlan.DurationInMonths months </p> <p class="mb-1"> <strong>Classes per week:</strong> @SelectedPlan.ClassSessionsPerWeek </p> <p class="mb-0"> <strong>Personal training sessions:</strong> @SelectedPlan.PersonalTrainingSessions </p> </div> } <!-- Required Start Date --> <div class="mb-3"> <label class="form-label fw-semibold"> Start Date <span class="text-danger">*</span> </label> <InputDate class="form-control" @bind-Value="formModel.StartDate" min="@DateTime.Today.ToString("yyyy-MM-dd")" /> <ValidationMessage For="@(() => formModel.StartDate)" class="text-danger" /> @if (formModel.StartDate.HasValue && formModel.StartDate.Value.Date < DateTime.Today) { <div class="text-danger small"> Start date cannot be in the past. </div> } </div> <!-- Automatically Calculated Expiry Preview --> <div class="mb-3"> <label class="form-label fw-semibold"> Expected Expiry Date </label> <input class="form-control" value="@ExpectedEndDate" readonly /> <div class="form-text"> Expiry = Start Date + Plan Duration. The backend calculates the final date. </div> </div> <!-- Required Payment Method --> <div class="mb-3"> <label class="form-label fw-semibold"> Payment Method <span class="text-danger">*</span> </label> <InputSelect class="form-select" @bind-Value="formModel.PaymentMethod"> <option value=""> -- Select Payment Method -- </option> <option value="DebitCard">Debit Card</option> <option value="CreditCard">Credit Card</option> <option value="UPI">UPI</option> <option value="BankTransfer">Bank Transfer</option> </InputSelect> <ValidationMessage For="@(() => formModel.PaymentMethod)" class="text-danger" /> </div> <!-- Required Terms Checkbox --> <div class="form-check mb-4"> <InputCheckbox class="form-check-input" id="terms" @bind-Value="formModel.TermsAccepted" /> <label class="form-check-label" for="terms"> I accept the terms and conditions. <span class="text-danger">*</span> </label> <ValidationMessage For="@(() => formModel.TermsAccepted)" class="text-danger d-block" /> </div> <!-- Submit --> <div class="d-flex gap-2"> <button type="submit" class="btn btn-primary" disabled="@isSubmitting"> @(isSubmitting ? "Submitting..." : "Subscribe Now") </button> <button type="button" class="btn btn-outline-secondary" @onclick="GoBack" disabled="@isSubmitting"> Cancel </button> </div> </EditForm> </div> </div> </div> </div> } </div> @code { [SupplyParameterFromQuery(Name = "gymId")] public int GymId { get; set; } [SupplyParameterFromQuery(Name = "planId")] public int PlanId { get; set; } private bool isLoading = true; private bool loadError; private bool isSubmitting; private string selectedGymName = string.Empty; private List<MembershipPlanItem> planList = new(); private SubscriptionFormModel formModel = new(); private MembershipPlanItem? SelectedPlan => planList.FirstOrDefault( p => p.Id == formModel.MembershipPlanId); private string ExpectedEndDate { get { if (SelectedPlan is null || !formModel.StartDate.HasValue || SelectedPlan.DurationInMonths <= 0) { return string.Empty; } try { return formModel.StartDate.Value .Date .AddMonths(SelectedPlan.DurationInMonths) .ToString("dd MMM yyyy"); } catch (ArgumentOutOfRangeException) { return string.Empty; } } } // Form model public sealed class SubscriptionFormModel { [Range(1, int.MaxValue, ErrorMessage = "Please select a membership plan.")] public int MembershipPlanId { get; set; } [Required(ErrorMessage = "Start date is required.")] public DateTime? StartDate { get; set; } = DateTime.Today; [Required(ErrorMessage = "Please select a payment method.")] public string PaymentMethod { get; set; } = string.Empty; [Range(typeof(bool), "true", "true", ErrorMessage = "You must accept the terms and conditions.")] public bool TermsAccepted { get; set; } } // Membership plan response public sealed class MembershipPlanItem { public int Id { get; set; } public string PlanName { get; set; } = string.Empty; public int GymId { get; set; } public decimal Price { get; set; } public int DurationInMonths { get; set; } public int ClassSessionsPerWeek { get; set; } public int PersonalTrainingSessions { get; set; } public bool LockerAccess { get; set; } public bool GuestAccess { get; set; } public int GuestPasses { get; set; } public string Status { get; set; } = string.Empty; } public sealed class GymItem { public int Id { get; set; } public string GymName { get; set; } = string.Empty; public string Status { get; set; } = string.Empty; } // Request sent to the backend. // UserId, EndDate, Status, PricePaid and SubscribedOn // must be determined by the backend, not the browser. public sealed class CreateSubscriptionRequest { public int GymId { get; set; } public int MembershipPlanId { get; set; } public DateTime StartDate { get; set; } public string PaymentMethod { get; set; } = string.Empty; public bool TermsAccepted { get; set; } } protected override async Task OnParametersSetAsync() { formModel.MembershipPlanId = PlanId; formModel.StartDate = DateTime.Today; await LoadFormData(); } private async Task LoadFormData() { isLoading = true; loadError = false; try { if (GymId <= 0 || PlanId <= 0) { throw new InvalidOperationException( "A valid gym and plan must be selected."); } // Adjust this URL to your actual gym endpoint. var gymResponse = await http.GetAsync( $"GymCenter/GetGymById?gymId={GymId}"); if (!gymResponse.IsSuccessStatusCode) { throw new InvalidOperationException( "Unable to load the selected gym."); } var gym = await gymResponse.Content .ReadFromJsonAsync<GymItem>(); if (gym is null || gym.Id != GymId || !IsActive(gym.Status)) { throw new InvalidOperationException( "The selected gym is unavailable."); } selectedGymName = gym.GymName; // Reuse your Browse Membership Plans endpoint. var planResponse = await http.GetAsync( $"MembershipPlan/BrowseMembershipPlans?GymId={GymId}"); if (!planResponse.IsSuccessStatusCode) { throw new InvalidOperationException( "Unable to load membership plans."); } var plans = await planResponse.Content .ReadFromJsonAsync<List<MembershipPlanItem>>(); planList = (plans ?? new List<MembershipPlanItem>()) .Where(p => p.GymId == GymId) .ToList(); var selectedPlan = planList.FirstOrDefault( p => p.Id == PlanId); if (selectedPlan is null || !IsActive(selectedPlan.Status)) { throw new InvalidOperationException( "The selected plan is unavailable."); } formModel.MembershipPlanId = selectedPlan.Id; } catch { loadError = true; planList = new(); await Swal.FireAsync(new SweetAlertOptions { Title = "Unable to load form", Text = "Check the gym and membership-plan API endpoints.", Icon = SweetAlertIcon.Error }); } finally { isLoading = false; } } private async Task SubmitSubscription() { if (isSubmitting) { return; } if (!formModel.StartDate.HasValue || formModel.StartDate.Value.Date < DateTime.Today) { await Swal.FireAsync(new SweetAlertOptions { Title = "Invalid Start Date", Text = "Choose today or a future date.", Icon = SweetAlertIcon.Warning }); return; } var plan = SelectedPlan; if (plan is null || plan.GymId != GymId || !IsActive(plan.Status)) { await Swal.FireAsync(new SweetAlertOptions { Title = "Invalid Plan", Text = "Select an active plan belonging to this gym.", Icon = SweetAlertIcon.Warning }); return; } var allowedPaymentMethods = new[] { "DebitCard", "CreditCard", "UPI", "BankTransfer" }; if (!allowedPaymentMethods.Contains(formModel.PaymentMethod)) { await Swal.FireAsync(new SweetAlertOptions { Title = "Invalid Payment Method", Text = "Select a valid payment method.", Icon = SweetAlertIcon.Warning }); return; } if (!formModel.TermsAccepted) { await Swal.FireAsync(new SweetAlertOptions { Title = "Terms Required", Text = "Accept the terms and conditions to continue.", Icon = SweetAlertIcon.Warning }); return; } isSubmitting = true; try { var request = new CreateSubscriptionRequest { GymId = GymId, MembershipPlanId = plan.Id, StartDate = formModel.StartDate.Value.Date, PaymentMethod = formModel.PaymentMethod, TermsAccepted = formModel.TermsAccepted }; // Adjust this URL to your actual subscription endpoint. var response = await http.PostAsJsonAsync( "Subscription/Subscribe", request); if (response.IsSuccessStatusCode) { await Swal.FireAsync(new SweetAlertOptions { Title = "Subscription Created", Text = "Your subscription was submitted successfully.", Icon = SweetAlertIcon.Success }); navigation.NavigateTo("/my-memberships"); } else { var error = await response.Content.ReadAsStringAsync(); await Swal.FireAsync(new SweetAlertOptions { Title = "Subscription Failed", Text = string.IsNullOrWhiteSpace(error) ? $"Request failed with status {(int)response.StatusCode}." : error, Icon = SweetAlertIcon.Error }); } } catch { await Swal.FireAsync(new SweetAlertOptions { Title = "Connection Error", Text = "Unable to submit your subscription.", Icon = SweetAlertIcon.Error }); } finally { isSubmitting = false; } } private static bool IsActive(string status) { return string.Equals( status, "Active", StringComparison.OrdinalIgnoreCase); } private void GoBack() { navigation.NavigateTo($"/gyms/{GymId}/plans"); } } ## 4. Important Blazor Concepts Used in This Component ### 4.1. Query-string parameters with `[SupplyParameterFromQuery]` The subscription page receives the gym and membership plan IDs from the URL.
razor
@page "/subscribe"
For example, the URL can be:
text
/subscribe?gymId=3&planId=5
The component reads these values using:
csharp
[SupplyParameterFromQuery(Name = "gymId")]
public int GymId { get; set; }
[SupplyParameterFromQuery(Name = "planId")]
public int PlanId { get; set; }
Blazor maps the query-string values to the corresponding properties. This is useful when one page needs to pass selected information to another page without storing it in a separate state-management service. **Important:** Query-string values are input from the client. The backend must still validate that the gym and membership plan exist, are active, and belong together. ### 4.2. `EditForm` and data validation Blazor's `EditForm` provides a structured way to collect and validate user input.
razor
>
<DataAnnotationsValidator /> <ValidationSummary class="text-danger mb-3" /> Here: - `Model` connects the form to the `formModel` object. - `DataAnnotationsValidator` enables validation using data annotations. - `ValidationSummary` displays validation errors. - `OnValidSubmit` invokes the submission method when the form passes its configured validation. For example:
csharp
[Required(ErrorMessage = "Start date is required.")]
public DateTime StartDate { get; set; } = DateTime.Today;
The `[Required]` attribute specifies that a value is required. The component also uses `[Range]` to prevent zero or negative IDs from being treated as valid selections. For the membership plan dropdown, the form uses `InputSelect`:
razor
@bind-Value="formModel.MembershipPlanId">
The `@bind-Value` directive connects the selected option to the model property. ### 4.3. The `@bind-Value:after` modifier The component contains:
razor
@bind-Value:after="UpdateEndDate"
This modifier lets you specify a callback to run after the bound value has been updated. In this component, it is used for both the selected membership plan and the start date. The `ExpectedEndDate` property calculates the preview using the currently selected plan's duration and the start date. The `UpdateEndDate` method itself is empty apart from comments because the preview is calculated by the property getter. The callback does not save the end date to the database. ### 4.4. Conditional rendering Blazor supports C# conditions directly in Razor markup. For example:
razor
@if (isLoading)
{
Loading...
}
else if (loadError)
{
Unable to load data.
}
else
{
Subscription form
}
This lets the component show different UI states according to the current application state. The subscription page uses this approach to display a loading spinner, an error message, an empty-state message, or the form. ### 4.5. Loading data from an API The component injects `HttpClient`:
razor
@inject HttpClient http
It then sends GET requests to retrieve the selected gym and its membership plans.
csharp
var plansResponse = await http.GetAsync(
$"MembershipPlan/BrowseMembershipPlans?GymId={GymId}");
The JSON response is converted into C# objects using:
csharp
var result = await plansResponse.Content
.ReadFromJsonAsync>();
The `MembershipPlanItem` class represents the response structure expected by this component. The API routes shown in the code are assumptions that must match your actual backend endpoints. ### 4.6. Calculating the expected end date The `ExpectedEndDate` property calculates a date from the selected plan and start date.
csharp
return formModel.StartDate
.AddMonths(SelectedPlan.DurationInMonths)
.ToString("dd MMM yyyy");
For example, if the start date is 1 January 2027 and the plan lasts three months, the preview will display the date returned by `AddMonths(3)`. The result is formatted as `dd MMM yyyy`, such as `01 Apr 2027`. This is only a preview. The backend must calculate and validate the final end date independently before saving the subscription. ### 4.7. Preventing repeated submissions The component uses a Boolean field:
csharp
private bool isSubmitting = false;
The submit button is disabled while the request is in progress:
razor
disabled="@isSubmitting"
The submission method also checks the flag before continuing:
csharp
if (isSubmitting)
{
return;
}
This helps prevent accidental repeated requests from the UI. It does not replace server-side protection against duplicate subscriptions or repeated API calls. ### 4.8. Sending the subscription to the backend The component creates a request object:
csharp
var request = new CreateSubscriptionRequest
{
GymId = GymId,
MembershipPlanId = selectedPlan.Id,
StartDate = formModel.StartDate.Date,
PaymentMethod = formModel.PaymentMethod,
TermsAccepted = formModel.TermsAccepted
};
It sends that object to the backend using:
csharp
var response = await http.PostAsJsonAsync(
"Subscription/Subscribe",
request);
The component checks whether the server returned a successful status code and displays an appropriate message. Notice that the request object does not contain `UserId`, `EndDate`, `Status`, or `PricePaid`. These values should be determined or verified by trusted server-side logic rather than accepted blindly from the client. ## 5. Another Demo: Using a Computed Property in Blazor Let's finish with another useful concept: **computed properties**. A computed property derives its value from other properties instead of storing a separate value that must be updated manually. Imagine a membership plan has a price and a duration.
razor
Membership Cost Calculator
Monthly Price: ₹@monthlyPrice
Duration in months:
Total Cost: ₹@TotalCost
@code {
private decimal monthlyPrice = 1000;
private int durationInMonths = 3;
private decimal TotalCost => monthlyPrice * durationInMonths; }
### How does it work? - `monthlyPrice` stores the monthly price. - `durationInMonths` stores the selected duration. - `TotalCost` calculates the result from both values. - When the bound duration changes, Blazor rerenders the component and evaluates the computed property again. If the monthly price is ₹1000 and the duration is three months, the total is ₹3000. This concept is also used in the membership subscription component:
csharp
private MembershipPlanItem? SelectedPlan =>
planList.FirstOrDefault(
plan => plan.Id == formModel.MembershipPlanId);
Rather than storing a separate selected-plan object and manually updating it every time the dropdown changes, the property finds the matching plan from the current list. The `ExpectedEndDate` property follows a similar pattern by deriving its result from the selected plan and start date. Computed properties are useful for summaries, totals, calculated dates, and other values that depend on current component state. ## Conclusion Blazor provides a convenient way to build interactive forms using Razor markup and C#. In this membership subscription example, we combined query-string parameters, data binding, form validation, conditional rendering, computed properties, API communication, and submission-state management. The main lesson is to separate responsibilities: Blazor handles the user interface and client-side experience, while the backend remains responsible for validating business rules and persisting trusted subscription data. These techniques can be applied to many other applications, including booking systems, e-commerce checkout pages, course registrations, and appointment scheduling forms. 