David Thielen
David Thielen

Reputation: 32996

Correctly passing Value back and forth to/from a Blazor child component

Ok, so for a child component in Blazor server, if the Value passed to it is a primitive (string, int, etc.) it's very simple to follow the contract of passed in to Value, passed back to ValueChanged. Because it's passed by value, making a change to Value in the child component is not seen in the parent.

But if you pass an object, now it can't be that easy, I think. Tell me if this is correct.

If it is passed as Value="address" then if I set Value.City = "Boulder"; that address.Name, in the parent component, will see the changed value. And that's breaking the contract which can cause problems. Correct?

And so, to handle this well I think requires the following:

  1. I have an _address object in the child component.
  2. In the child's SetParametersAsync() I have to watch for the parameter Value being passed in. When it is, I set _address= parameter.Value;.
  3. I call ValueChanged when I see a change. The parent then should update Value to match. (And if it doesn't, that's on them.)

AddressForm.razor I can do binding - because it's to my internal _value, not to the public Value.

<DxTextBox @bind-Text="_value.StreetNumberAndName"
           ReadOnly="ReadOnly"
           ShowValidationIcon="true" />

Then in my code behind I do:

public AddressForm()
{
    // to avoid null pointer issues until SetParametersAsync is called
    Value = new AddressFormPageModel();
    _value = new AddressFormPageModel();

    // we start with it set to true until we get the first Value= set below.
    IsSelected = true;
}

public override async Task SetParametersAsync(ParameterView parameters)
{
        parameters.SetParameterProperties(this);

        foreach (var parameter in parameters)
        {
            if (parameter.Name != nameof(Value))
                continue;
            if (parameter.Value is not AddressFormPageModel newValue)
                continue;
            _value = new AddressFormPageModel(newValue);

            // we hit this code including the first time it processes Value= which
            // is the initialization. This determines if the address is initially
            // verified. 
            // And it first creates an empty Value, then sets the passed in one
            if (IsFirstSet && ((! string.IsNullOrEmpty(_value.StreetNumberAndName)) 
                               || (! string.IsNullOrEmpty(_value.City))))
            {
                IsSelected = Value.IsVerified;
                IsFirstSet = false;
            }
        }

        await base.SetParametersAsync(ParameterView.Empty);
}
private async void OnFieldChanged(object? sender, FieldChangedEventArgs e)
{
    // we're tied to the EditContext of the parent component, so we need to make sure
    // this is for one of our fields
    if (e.FieldIdentifier.Model is not AddressFormPageModel)
        return;

    if (ValueChanged.HasDelegate)
        await ValueChanged.InvokeAsync(_value);
}

And the finally, in the parent razor code:

private void OnAddressChanged(AddressFormPageModel address)
{
    HasUnsavedChanges = true;
    IsAddressVerified = false;
    // note in the razor it has Value="ProfilePageModel.AddressModel"
    ProfilePageModel.AddressModel = address;
}

Is this correct?

Upvotes: 0

Views: 5458

Answers (3)

MrC aka Shaun Curtis
MrC aka Shaun Curtis

Reputation: 30320

As you haven't given much context, I'm making some educated guesses based on previous questions.

Let's assume you have something like this. Everything is a class, which I take from the question being about reference types.

public class Person {
    public int PersonId {get; set;}
    public string? Name { get; set; }
    public Address Address { get; set; } = new();
}
public class Address
{
    public string? City { get; set; }
    public string? Country { get; set; }
}

So if you have an edit control for Address you do something like this:

<EditForm Model="_person">
    <AddressEditor @bind-Value="_person.Address" />
</EditForm>

@code{
    private Person _person = new();

And in AddressEditor you have:

@code {
    [Parameter] public Address Value { get; set; } = new();
    [Parameter] public EventCallback<Address> ValueChanged { get; set; }

It's important to recognise that Value in the edit control and _person in the parent are all references to the same object. Change the value of City in an input in AddressEditor you have changed the value of _person.City. You won't see that in the UI until it renders, but it's there in the C# object. So you don't actually need to pass the change back though the EventCallback for the parent [and the data pipeline to see it], and you don't need to jump through hoops to try to track changes in AddressEditor.

Here's an AddressEditor to demo one way of addressing the problem. It uses internal fields for the individual input controls to bind to and the bind:after to update Value. Note I'm doing what @HH and I say is a NONO in updating the [Parameter] because I think it's a valid to do so here. It's a reference type not a value type. There's no mismatch happening, everyone is referencing the same object. One alternative approach is to build a new copy of Address each time, but that will probably cause issues in your complex model further back down your EF data pipeline. The other is to have bind values for each field, which is rather long winded.

@implements IDisposable

<div>
    <label class="form-label">City</label>
    <InputText class="form-control" @bind-Value="_city" @bind-Value:after="CityChanged" />
    <ValidationMessage For="() => Value.City" />
</div>
<div>
    <label class="form-label">Country</label>
    <InputText class="form-control" @bind-Value="_country" @bind-Value:after="CountryChanged" />
</div>

@code {
    [CascadingParameter] private EditContext editContext { get; set; } = default!;
    [Parameter] public Address Value { get; set; } = new();
    [Parameter] public EventCallback<Address> ValueChanged { get; set; }

    private ValidationMessageStore? _messageStore;
    private string? _city;
    private string? _country;

    protected override void OnInitialized()
    {
        ArgumentNullException.ThrowIfNull(editContext);
        _messageStore = new ValidationMessageStore(editContext);
        editContext.OnValidationRequested += Validate;
    }

    private async Task CityChanged()
    {
        Value.City = _city;
        ValidateCity();
        await this.ValueChanged.InvokeAsync(this.Value);
    }

    private async Task CountryChanged()
    {
        Value.Country = _country;
        await this.ValueChanged.InvokeAsync(this.Value);
    }

    private void Validate( object? sender, ValidationRequestedEventArgs e)
    {
        ValidateCity();
    }

    private void ValidateCity()
    {
        var fi = new FieldIdentifier(Value, "City");
        var messages = editContext.GetValidationMessages(fi);

        var wasValid = !messages.Any();
        var isValid = this.Value.City?.Length > 2;

        if (wasValid && !isValid)
        {
            _messageStore?.Add(fi, "City must have more than 2 characters");
            editContext.NotifyValidationStateChanged();
        }

        if (!wasValid && isValid)
        {
            _messageStore?.Clear(fi);
            editContext.NotifyValidationStateChanged();
        }
    }

    public void Dispose()
    {
        editContext.OnValidationRequested -= Validate;
    }
}

and:

@page "/"

<PageTitle>Index</PageTitle>

<h1>Hello, world!</h1>

<EditForm Model="_person">
    
    <AddressEditor @bind-Value="_person.Address" />

</EditForm>

<div class="bg-dark text-white m-2 p-2">
    <pre>City: @_person.Address.City</pre>
    <pre>Country: @_person.Address.Country</pre>
</div>
@code{
    private Person _person = new();

    public class Person {
        public string? Name { get; set; }
        public Address Address { get; set; } = new();
    }
}

Note that I would use a value object for address. But that's another story.

A note on the "don't set a [Parameter] rule"

You don't set [Parameter] defined properties in a component because by doing so you create a conflict between the value in the component, the value held by the Renderer and the source value in the parent.

However, when dealing with reference objects, such as a POCO, there are no longer three values, just three references to the same object. Change one and you change all. Returning the reference in an EventCallback does nothing.

With reference objects, there's no longer a conflict. All rules are made to be broken, you just need to understand when you can and can't do so.

Upvotes: 1

MrC aka Shaun Curtis
MrC aka Shaun Curtis

Reputation: 30320

For the record here's a simple Value Object implementation:

There's no longer any Parameter Value setting, a new AddressValue is returned each time in the EventCallback.

BUT, you can't do a simple bind to AddressValueEditor any more. Address is now a AddressValue : an immutable value object. You need a custom setter.

public record AddressValue
{
    public string? City { get; init; }
    public string? Country { get; init; }
}

AddressValueEditor:

<div>
    <label class="form-label">City</label>
    <InputText class="form-control" @bind-Value="_city" @bind-Value:after="CityChanged" />
    <ValidationMessage For="() => Value.City" />
</div>
<div>
    <label class="form-label">Country</label>
    <InputText class="form-control" @bind-Value="_country" @bind-Value:after="CountryChanged" />
</div>

@code {
    [CascadingParameter] private EditContext editContext { get; set; } = default!;
    [Parameter] public AddressValue Value { get; set; } = new();
    [Parameter] public EventCallback<AddressValue> ValueChanged { get; set; }

    private ValidationMessageStore? _messageStore;
    private string? _city;
    private string? _country;

    protected override void OnInitialized()
    {
        ArgumentNullException.ThrowIfNull(editContext);
        _city = this.Value.City;
        _country = this.Value.Country;
        _messageStore = new ValidationMessageStore(editContext);
    }

    private async Task CityChanged()
    {
        var newAddress = this.Value with { City = _city };
        await this.ValueChanged.InvokeAsync(newAddress);
    }

    private async Task CountryChanged()
    {
        var newAddress = this.Value with { Country = _country };
        await this.ValueChanged.InvokeAsync(newAddress);
    }
}

And demo:

@page "/"

<PageTitle>Test</PageTitle>

<h1>Hello, world!</h1>

<EditForm Model="_person">

    <AddressValueEditor @bind-Value:get="_person.Address" @bind-Value:set="this.SetAddress" />

</EditForm>

<div class="bg-dark text-white m-2 p-2">
    <pre>City: @_person.Address.City</pre>
    <pre>Country: @_person.Address.Country</pre>
</div>
@code {
    private Person _person = new();

    private void SetAddress(AddressValue address)
    {
        _person.Address = address;   
    }

    public class Person
    {
        public string? Name { get; set; }
        public AddressValue Address { get; set; } = new();
    }
}

Upvotes: 2

DM-98
DM-98

Reputation: 665

(SEE FULL CODE EXAMPLE AT BOTTOM)

The easiest way to pass complex(or any valuetypes) objects from a Child component to a Parent component, and changing its value and reflecting it back to the parent component, is by using CascadingValue (CascadingParameter) and EventCallback;

  1. In Parent: Surround the child component with CascadingValue and the object 'myCascadedAddress' with a name:
<CascadingValue Value="@myCascadedAddress" Name="AKeyNameToCatchThisObject">
    <MyChildComponent OnAddressChanged="OnAddressChangedFromChild" />
</CascadingValue>
  1. In Parent: Create a method that sets its value when the child changes it:
void OnAddressChangedFromChild(Address changedAddress) => myCascadedAddress = changedAddress;
  1. In Child: Catch the cascaded value by using CascadingParameter:
[CascadingParameter(Name = "AKeyNameToCatchThisObject")] // remember Name from step 1, to set its value
Address CascadedAddressObject { get; set; } = null!;
  1. In Child: Create the EventCallback property and make it a Parameter (see 1st step):
[Parameter]
public EventCallback<Address> OnAddressChanged { get; set; }
  1. In Child: Change the address and send the value to the parent method 'OnAddressChangedFromChild':
async Task ChangeCityAsync()
{
    CascadedAddressObject.City = "Seattle"; // Changes the address object

    await OnAddressChanged.InvokeAsync(CascadedAddressObject); // Calls back to parent with new address object
}

Full code example:

MyParentComponent.razor:

@page "/"

<h1>Parent</h1>

<p>Parent: @myCascadedAddress?.City</p>

<CascadingValue Value="@myCascadedAddress" Name="AKeyNameToCatchThisObject">
    <MyChildComponent OnAddressChanged="OnAddressChangedFromChild" />
</CascadingValue>

@code {
    public sealed class Address { public string City { get; set; } = null!; }

    Address? myCascadedAddress;

    protected override void OnInitialized() => myCascadedAddress = new Address { City = "Redmond" };

    void OnAddressChangedFromChild(Address changedAddress) => myCascadedAddress = changedAddress;
}

MyChildComponent.razor:

@using static BlazorApp7.Pages.MyParentComponent; // cuz Address object defined in parent

<hr />

<h1>Child</h1>

<p>Child: @CascadedAddressObject.City</p>

<button class="btn btn-primary" @onclick="ChangeCityAsync">Change City from Child</button>

<hr />

@code {
    [CascadingParameter(Name = "AKeyNameToCatchThisObject")] // Name from parent must match in order to set its value
    private Address CascadedAddressObject { get; set; } = null!;

    [Parameter]
    public EventCallback<Address> OnAddressChanged { get; set; }

    private async Task ChangeCityAsync()
    {
        CascadedAddressObject.City = "Seattle"; // Changes the address object

        await OnAddressChanged.InvokeAsync(CascadedAddressObject); // Calls back to parent with new address object
    }
}

The alternative:

What you're doing - using SetParameters{Async}, which is good, if you want to catch the parameter value as soon as it becomes available and manipulating the value BEFORE it is being set - which is not always necessary, since you can use attributes/parameters (like my code above).

OnParametersSet{Async} will be called anytime a [CascadingParameter]/[Parameter] becomes available or changed, which you can make use of if you want, and is more commonly used than SetParameters{Async}.

Upvotes: 1

Related Questions