Updated on
On a plain <select>, @bind and @onchange cannot coexist. The compiler reports the onchange attribute as used twice. On an InputSelect, the same pair compiles without complaint, which is worse: the component captures our handler and then overwrites it with its own binder, so it never runs.
The fix is the same shape either way. To react once the value has already been bound, use @bind:after (or @bind-Value:after on InputSelect). To handle the change without binding at all, drop @bind and use @onchange alone. On an InputSelect that means supplying Value, ValueChanged, and ValueExpression by hand.
Both <select> and InputSelect are Blazor components, and the mechanism underneath both is data binding in Blazor.
@onchange vs @bind in Blazor
Our application is a simple burger configurator where we choose the topping from a dropdown menu. We will create a new Blazor WebAssembly application with Visual Studio Project Wizard or use the terminal dotnet new blazorwasm command.
In the code section of our index.razor file we add:
@code
{
public record Topping(int Id, string Name);
public string? selectedTopping;
public List<Topping> toppings = new List<Topping>()
{
new Topping(1, "No Topping"),
new Topping(2, "Cheese"),
new Topping(3, "Onions"),
new Topping(4, "Pickles"),
new Topping(5, "Avocado"),
new Topping(6, "Lettuce")
};
}
We create a Topping record type and initialize several Topping records with Id and Name. We also declare a string variable selectedTopping.
In the body of the index.razor, we add:
@page "/"
<PageTitle>Burger Configurator</PageTitle>
<h1>Burger Configurator</h1>
<p>Free topping: @selectedTopping</p>
<p>Please choose your topping from the list below:</p>
<span>Free topping:</span>
<select class="select-element" @bind="selectedTopping">
@foreach (var topping in toppings)
{
<option value="@topping.Name">@topping.Name</option>
}
</select>
To use onchange event with select dropdown, we add a <select> element with a foreach loop that goes through our toppings and displays it as a dropdown in our application UI. Notice that in the <select> element, we use @bind to bind the selected value in the dropdown with our variable @selectedTopping.
When we run the application and select a topping, the topping appears in the line:
<p>Free topping: @selectedTopping</p>
It seems that there is an @onchange event present even though we didn’t declare it in our <select> element.
It happens because @bind has @onchange event registered which handles changes, so our line:
<select class="select-element" @bind="selectedTopping">
is equal to:
<select class="select-element" value="@selectedTopping" @onchange="@((ChangeEventArgs e) => selectedTopping = e.Value.ToString())">
Our <select> element has an @onchange event attribute with lambda expression where it assigns the selected value to the @selectedTopping variable after the change event fires.
The @bind is a good choice for works storing the value in the variable after the change, but what if we need to perform additional computations based on the selected value?
Let’s see how we use the @onchange event to calculate the cost of a burger based on the topping we select.
How Do We Use @onchange Without @bind?
Drop @bind and hand @onchange a method: Blazor calls it with a ChangeEventArgs whose Value carries the option the user picked, and nothing is written to a field unless we write it ourselves. That is the whole trade. Binding hands us the value for free and leaves nowhere to put logic, while @onchange on its own hands us the event and makes the state ours to manage.
The handler signature is the only ceremony. It takes one ChangeEventArgs parameter and returns void or Task, and args.Value arrives as an object holding the selected option’s value attribute as a string, so an integer id needs parsing before it is any use.
This is the shape to reach for when a selection triggers work rather than just recording a choice: recalculating a total, filtering a second dropdown, or calling an API. We use it here to price a burger as toppings are chosen.
Firstly, let’s change our Topping record:
@code
{
public record Topping(int Id, string Name, double Price);
public string? selectedTopping;
public string? selectedSecondTopping;
public List<Topping> toppings = new List<Topping>()
{
new Topping(1, "No Topping", 0),
new Topping(2, "Cheese", 2.4),
new Topping(3, "Onions", 0.7),
new Topping(4, "Pickles", 1.3),
new Topping(5, "Avocado", 4.6),
new Topping(6, "Lettuce", 1.1)
};
}
In the Topping record declaration, we add a double type Price and add prices for each topping when we initialize the toppings list, we also add a selectedSecondTopping string variable.
We add a Price preceded by + and $ signs in our foreach loop. This way, we can see the $ amount each topping adds when we select it:
@foreach (var topping in toppings)
{
<option value="@topping.Name">@topping.Name [email protected]</option>
}
Let’s add a method we will use in our @onchange event:
@code
{
public record Topping(int Id, string Name, double Price);
public string? selectedTopping;
public string? selectedSecondTopping;
public static double baseBurgerCost = 5.4;
public double totalCost = baseBurgerCost;
public List<Topping> toppings = new List<Topping>()
{
new Topping(1, "No Topping", 0),
new Topping(2, "Cheese", 2.4),
new Topping(3, "Onions", 0.7),
new Topping(4, "Pickles", 1.3),
new Topping(5, "Avocado", 4.6),
new Topping(6, "Lettuce", 1.1)
};
public void HandleChange(ChangeEventArgs args)
{
@foreach(var topping in toppings)
{
if(topping.Id == int.Parse(args.Value.ToString()))
{
selectedSecondTopping = topping.Name;
totalCost = Math.Round(baseBurgerCost + topping.Price, 2);
}
}
}
}
First, we add a baseBurgerCost variable which is a cost without a topping, and a totalCost variable initialized with the baseBurgerCost value.
Then, we create a HandleChange() method that executes when the @onchange event fires. Inside, we have a foreach loop that goes through toppings and checks whether the id of the currently selected topping equals a topping from the List. When it finds a match, it assigns a name to the selectedSecondTopping variable, calculates the total cost of the burger, and sets it to the totalCost variable.
We also need to adapt our page body:
@page "/"
<PageTitle>Burger Configurator</PageTitle>
<h1>Burger Configurator</h1>
<p>Burger without topping: $@baseBurgerCost</p>
<p>Free topping: @selectedTopping</p>
<p>Total Cost: $@totalCost</p>
<p>Please choose your toppings from the list below:</p>
<span>Free topping:</span>
<select class="select-element" @bind="selectedTopping">
@foreach (var topping in toppings)
{
<option value="@topping.Name">@topping.Name</option>
}
</select>
<span>Second topping:</span>
<select class="select-element" @onchange="@HandleChange">
@foreach (var topping in toppings)
{
<option value="@topping.Id">@topping.Name [email protected]</option>
}
</select>
We add baseBurgerCost and totalCost variable outputs to see the results.
Then, we set the topping id as a value of the <option> element and assign the HandleChange() method to the @onchange attribute.
Our entire logic for total cost calculation is now inside the HandleChange() method which executes when the @onchange event fires.
Can We Use @bind With @onchange?
No, not on a plain <select>. The two cannot share an element, because @bind already registers an onchange handler of its own, so a second one is a duplicate attribute and the compiler stops the build.
Awkward as that looks, it is the useful behaviour. The failure arrives at build time with a message naming the exact conflict, so nobody ships a page where one of two handlers silently loses and the other wins.
There are two ways to get what the pairing was reaching for. Keeping the binding, @bind:after runs our method once the new value has been written to the field. Dropping the binding, @onchange on its own gives us the event and leaves the state to us.
InputSelect is the case to watch, because it accepts both attributes and compiles clean while still discarding our handler. The section “What Happens If We Use @bind-Value and @onchange Together on InputSelect?” takes that apart.
Let’s add @bind to our <select> element:
<select class="select-element" @bind="@selectedSecondTopping" @onchange="@HandleChange">
@foreach (var topping in toppings)
{
<option value="@topping.Id">@topping.Name [email protected]</option>
}
</select>
The compiler will immediately complain and show us an error on the @onchange attribute. The reason is that @bind uses @onchange internally, so it is not possible to use @bind and @onchange on the same element, as it throws an error:
The attribute 'onchange' is used two or more times for this element. Attributes must be unique (case-insensitive). The attribute 'onchange' is used by the '@bind' directive attribute.
What Happens If We Use @bind-Value and @onchange Together on InputSelect?
InputSelect is the EditForm version of a dropdown, and it binds with @bind-Value rather than @bind. What happens when we add @onchange is not what the plain <select> does.
InputSelect builds without complaint. It inherits AdditionalAttributes, a parameter that captures every attribute the component does not declare, so @onchange lands there instead of erroring. Then the component renders: it splats those captured attributes first and writes its own onchange binder afterwards. The later write wins, and our handler is discarded before the browser ever sees it.
That is the trap. There is no error, no warning, and no runtime exception. The dropdown simply changes value and our method never runs.
There is a second reason not to fight it. InputSelect reports changes to the EditContext so validation runs, and bypassing the binding to attach a raw handler skips that.
InputSelect lives inside an EditForm, and if that pairing is new, forms and form validation in Blazor WebAssembly covers the surrounding pieces this section assumes.
How Do We Handle Change Events on a Blazor InputSelect?
There are two supported patterns, and the choice is whether we still want the value bound.
Keeping the binding is almost always right. @bind-Value:after takes a method that runs once the new value has been written to the model, so the component still reports the change to its EditContext and validation runs as it should.
Replacing the binding is the other option, and it means supplying by hand what @bind-Value was generating: a Value parameter, a ValueChanged callback, and a ValueExpression. Omitting ValueExpression compiles and then breaks validation at runtime, which is the usual reason this route disappoints.
The callback also becomes ours to honour. ValueChanged is an EventCallback<T> that we must invoke ourselves for the parent to see the new value, and forgetting that leaves a dropdown that visibly changes while the model never does.
Prefer the first pattern unless the component genuinely must not own the value.
Here is that first pattern on an InputSelect:
<InputSelect @bind-Value="order.ToppingId" @bind-Value:after="RecalculateTotal">
@foreach (var topping in toppings)
{
<option value="@topping.Id">@topping.Name</option>
}
</InputSelect>
RecalculateTotal takes no parameters and reads the model, because by the time it runs the new value is already there. It may return Task, so awaiting a price lookup works without extra ceremony.
| What we need | Plain <select> | InputSelect inside EditForm |
|---|---|---|
| Store the value only | @bind="value" | @bind-Value="model.Value" |
| Run code after the value is stored | @bind:after="Handler" | @bind-Value:after="Handler" |
| Handle the change instead of binding | @onchange="Handler" (no @bind) | ValueChanged + Value + ValueExpression |
Both binding and @onchange on one element | Compiler error: onchange used twice | Compiles, then silently discards our handler |
| Handler receives | ChangeEventArgs with Value as object? | The already-typed value |
| Async handler | Yes, @bind:after accepts a Task method | Yes, @bind-Value:after accepts a Task method |
| Validation integration | None | Yes, participates in EditContext |
The ordering is documented, not incidental. ASP.NET Core Blazor data binding puts it this way: “An assigned C# delegate isn’t executed until the bound value is assigned synchronously”.
Getting the ValueExpression parameter right is also what keeps custom validation in Blazor WebAssembly working once the field is wired up.
How Do We Run Code After @bind Updates the Value?
@bind:after has been the standard approach since .NET 7 and is current on .NET 10. The assigned method won’t execute until the synchronous bind for the value is complete. The method we pass to @bind:after needs to return either Task or Action. In this scenario, the <select> element has a @bind attribute that binds the value of the selected element. Then, the @bind:after is executed asynchronously after @bind is bound to the selected element’s value.
The ordering is the point. Because the write lands first, our method reads the model rather than a ChangeEventArgs, which is why a @bind:after handler takes no parameters at all.
On an InputSelect, the equivalent is @bind-Value:after, covered in “How Do We Handle Change Events on a Blazor InputSelect?” above.
ASP.NET Core Blazor data binding states one constraint word for word: “Using an event callback parameter (EventCallback/EventCallback<T>) with @bind:after isn’t supported.” Pass a method returning Action or Task instead.
Let’s see a @bind:after example with a second topping:
@page "/"
<PageTitle>Burger Configurator</PageTitle>
<h1>Burger Configurator</h1>
<p>Burger without topping: $@baseBurgerCost</p>
<p>Free topping: @selectedTopping</p>
<p>Total Cost: $@totalCost</p>
<p>Grand Total: $@grandTotal</p>
<p>Please choose your toppings from the list below:</p>
<span>Free topping:</span>
<select class="select-element" @bind="selectedTopping">
@foreach (var topping in toppings)
{
<option value="@topping.Name">@topping.Name</option>
}
</select>
<span>Second topping:</span>
<select class="select-element" @onchange="@HandleChange">
@foreach (var topping in toppings)
{
<option value="@topping.Id">@topping.Name [email protected]</option>
}
</select>
<span>Third topping:</span>
<select class="select-element" @bind="selectedThirdTopping" @bind:after="CalculateGrandTotal">
@foreach (var topping in toppings)
{
<option value="@topping.Id">@topping.Name [email protected]</option>
}
</select>
We add another <select> element along with a @grandTotal output.
Next, we add the following variables and a method to our @code section:
public string? selectedThirdTopping;
public double grandTotal;
public async Task CalculateGrandTotal()
{
await Task.Delay(2000);
foreach (var topping in toppings)
{
if (topping.Id == int.Parse(selectedThirdTopping))
{
grandTotal = Math.Round(totalCost + topping.Price, 2);
}
}
}
With Task.Delay() we simulate an asynchronous request that will fetch data and return a result after a certain amount of time. The important thing to note here is that our asynchronous logic will execute only after the value has been bound to the variable set to the @bind attribute. This way we can use the selected value to make an asynchronous request and return data.
Conclusion
User input is a core part of any user-facing application. We need to get a currently selected value and be able to apply additional logic after the change occurs.
We explored the use of onchange event with the select dropdown in a Blazor application by using the @bind to assign the current value to the variable as it already internally contains an onchange event.
When we have our logic in a separate method, we can use the onchange attribute to execute our code when the change occurs. We can use the @bind:after attribute for asynchronous methods, as it will run after the new value is bound to the assigned variable.
The ChangeEventArgs shape used throughout this article is part of Blazor’s broader custom event arguments in Blazor model.
Tested with .NET 10.0.10.
