'Two-Way Component Parameter Binding on a class in Blazor?

I have a class MealsQueryInputs that I would like to use as a component parameter with two-way binding capabilities.

All of the demos and sample code I can find are using built-in primitive types and never a class. I can get the MS demos to work but I cannot get binding to a class to work. Is it even possible to do this?

My component FilterSortOptions.razor:

using WhatIsForDinner.Shared.Models

<MudCheckBox Checked="@QueryInputs.Favorite" 
             Color="Color.Inherit" 
             CheckedIcon="@Icons.Material.Filled.Favorite" 
             UncheckedIcon="@Icons.Material.Filled.FavoriteBorder"
             T="bool"/>
<MudRating SelectedValue="@QueryInputs.Rating"/>
<MudButton OnClick="@(async () => await OnPropertyChanged())">Apply</MudButton>

@code {
    [Parameter]
    public MealsQueryInputs QueryInputs { get; set; }

    [Parameter]
    public EventCallback<MealsQueryInputs> QueryInputsChanged { get; set; }

    private async Task OnPropertyChanged()
    {
        await QueryInputsChanged.InvokeAsync(QueryInputs);
    }
}


Solution 1:[1]

Updated Answer

Firstly, if your using an object then you are passing around references to the same object. So when you update the object in the sub-component, you're updating the same object the parent is using. You don't need to pass the object back in the callback unless you create a noew copy of it.

Secondly, your not binding the mud controls to the object.

Let's look at your code:

<MudCheckBox Checked="@QueryInputs.Favorite" 
             Color="Color.Inherit" 
             CheckedIcon="@Icons.Material.Filled.Favorite" 
             UncheckedIcon="@Icons.Material.Filled.FavoriteBorder"
             T="bool"/>

Checked="@QueryInputs.Favorite" doesn't bind the control to the field. It just sets the initial value.

I think (I don't use Mudblazor and it's a little different from standard Blazor Form Controls) you need to do this:

<MudCheckBox @bind-Checked="@QueryInputs.Favorite"></MudCheckBox>

The same is true for MudRating.

    <MudRating @bind-SelectedValue="@QueryInputs.Rating" />

Then the button:

<MudButton OnClick="@(async () => await OnPropertyChanged())">Apply</MudButton>

can be simplified to this. You're wrapping an async method within an async method.

<MudButton OnClick="OnPropertyChanged">Apply</MudButton>
// or
<MudButton OnClick="() => OnPropertyChanged()">Apply</MudButton>

Original Answer

There are a couple of issues here:

  1. QueryInputs is a Parameter and therefore should never be modified by the code within the component. You end up with a mismatch between what the Renderer thinks the value is and what it actually is.

  2. When the parent component renders it will always cause a re-render of any component that is passed a class as a parameter. The Renderer has no way of telling if a class has been modified, so it applies the heavy handed solution - call SetParametersAsync on the component.

A solution is to use a view service to hold the data and events to notify changes. One version of the truth! Search "Blazor Notification Pattern" for examples of how to implement this. I'll post some code if you can't find what you want.

Solution 2:[2]

As MrC said, you should avoid directly binding to the data being supplied as a parameter.

Here is a simple working sample (not MudBlazor) to show the concept

https://blazorrepl.telerik.com/QQEnQjaO54LY3MYK35

You bind to a local variable/property and try not to modify the incoming data directly.

MyComponent


<h1>MyComponent</h1>

<label for="choice">Choose</label>
<input id="choice" type="checkbox" @bind-value=localValue  />

@code
{
    bool localValue
    {
        get => Data.SomeChoice;
        set {
            if (value != localValue)
            {
                localData = Data with { SomeChoice = value };
                InvokeAsync(ValueChanged);
            }
        }
    }
    ComplexObject localData;
    [Parameter] public ComplexObject Data { get; set; }
    [Parameter] public EventCallback<ComplexObject> DataChanged { get; set; }

    Task ValueChanged() => DataChanged.InvokeAsync(localData);
}

ComplexObject

public record ComplexObject(bool SomeChoice, string SomeText);

Main

@code
{
    ComplexObject data = new(false,"");
}

<MyComponent @bind-Data=data />

You have chosen @data.SomeChoice

Solution 3:[3]

Here is how you can bind class objects to a custom razor component

This is FilterSortOptions component

<div>
    <label>Rating:</label>
    <input type="text" [email protected] @oninput=@(val=> {
           QueryInputs.Rating=val.Value.ToString();
           QueryInputsChanged.InvokeAsync(QueryInputs);
           }) />
</div>

<div>
    <label>Favourite:</label>
    <input type="checkbox" [email protected] @onchange=@(val=> {
           QueryInputs.Favourite=(bool)val.Value;
           QueryInputsChanged.InvokeAsync(QueryInputs);
           }) />
</div>

    @code {
    
        [Parameter]
        public MealsQueryInputs QueryInputs { get; set; }
    
        [Parameter]
        public EventCallback<MealsQueryInputs> QueryInputsChanged { get; set; }
    
    }

This is the model to bind, for simplicity Rating is is string type

public class MealsQueryInputs
{
    public bool Favourite { get; set; } = false;
    public string Rating { get; set; } = "0";
}

Here is the razor page

<h3>Rating: @QueryInputs.Rating</h3>
<h3>Favourite: @QueryInputs.Favourite</h3>

<FilterSortOptions @bind-QueryInputs=@QueryInputs></FilterSortOptions>

@code {
    public MealsQueryInputs QueryInputs = new();
}

Sources

This article follows the attribution requirements of Stack Overflow and is licensed under CC BY-SA 3.0.

Source: Stack Overflow

Solution Source
Solution 1
Solution 2 Mister Magoo
Solution 3 Surinder Singh