Search code examples
razorblazorblazor-webassembly.net-7.0

Update element in root component when child component changes object


I have a Blazor Webassembly project with a custom razor component.

@page "/resume"
@using Resume.Shared.Dto.Resume
@layout ResumeEditorLayout
 
@inject ResumeDto ResumeDto

<CascadingValue Value="ResumeDto" IsFixed="true">
    <div class="row d-flex">
        <div class="col fixed-width fields">
            <h1>A: @ResumeDto.Personalia.FirstName</h1>
            <Personalia />
            
            <h1>B: @ResumeDto.Personalia.LastName</h1>
            <Input @bind-Value="ResumeDto.Personalia.LastName" Label="Last name" />
        </div>
    </div>
</CascadingValue>

The ResumeDto class is registered as a singleton:

builder.Services.AddSingleton<ResumeDto>();

The razor component also uses a Personalia component that looks like this:

@using Resume.Shared.Dto.Resume

<div class="row">
    <div class="col">
        <Input @bind-Value="ResumeDto.Personalia.FirstName" Label="First name" />
        <Input @bind-Value="ResumeDto.Personalia.Email" Label="Email address" />
    </div>
</div>

@code {
    [CascadingParameter]
    public ResumeDto ResumeDto { get; set; } = null!;
}

This results in a simple web page that looks like this:

enter image description here

When I type something in the Firstname input field I expected it to update the first <h1> tag. But this doesn't happen. This seems to be because the input fields are inside a child component.

But when I type something in the Lastname input field, then I see that the second <h1> tag gets updates immediately.

I want to abstract parts away in child components. But in a way that the main parent component's ResumeDto is up to date. I thought that CascadingParameter would pass the object as reference.

But why doesn't my first <h1> get updated doesn't get updated when I type something in the input field? It does work when I put the input fields directly into the page. But like I said, I would like to abstract certain parts into child components.

How can I fix this issue?


Input.razor (custom component)

I also use a custom component to render the input fields with a label. That looks like this:

<div class="form-control-wrapper">
    <label for="@Id" class="form-label col-form-label-sm d-inline-block">@Label</label>
    <InputText class="form-control" @bind-Value="@Value" @oninput="e => Value = e.Value?.ToString()" />
</div>

@code {
    [Parameter]
    public string? Label { get; set; }

    private string? _value;

    [Parameter]
    public string? Value
    {
        get => _value;
        set
        {
            if (_value != value)
            {
                _value = value;
                ValueChanged.InvokeAsync(value);
            }
        }
    }

    [Parameter]
    public EventCallback<string> ValueChanged { get; set; }

    private string Id { get; set; } = $"id_{Guid.NewGuid():N}";
}

Solution

  • You are attempting to make a multi-part edit form. The problems with such a design is in component to component communications.

    How does component X know that the model has been changed by component Y? You start wiring up callbacks, and ..... get into a mess with calls to stateHasChanged.

    This answer is based on implementing some code patterns covered in this Repo - https://github.com/ShaunCurtis/Blazr.EditContext.

    The key is to use the EditContext as the cascade (Model is the data) and OnFieldChanged to update components when a field is changed.

    So assuming we have a PersonEditContext based on a PersonData record that looks like this:

    public record PersonRecord
    {     
        public string? FirstName { get; init; }
        public string? LastName { get; init; }
    }
    

    This is how to refactor your Input to inherit directly from InputText:

    @inherits InputText
    
    <div class="form-control-wrapper">
        <label class="form-label col-form-label-sm d-inline-block">@this.Label</label>
        @this.content
    </div>
    
    @code {
        [Parameter] public string? Label { get; set; }
    
        // this gets the render code from the parent InputText class
        private RenderFragment content => (builder) => base.BuildRenderTree(builder);
    }
    

    And if you want to update oninput then this:

    @using Microsoft.AspNetCore.Components.Rendering;
    @inherits InputText
    
    <div class="form-control-wrapper">
        <label class="form-label col-form-label-sm d-inline-block">@this.Label</label>
        @this.InputContent
    </div>
    
    @code {
        [Parameter] public string? Label { get; set; }
        [Parameter] public bool UpdateOnInput { get; set; }
        
        protected RenderFragment InputContent => (RenderTreeBuilder builder) =>
        {
            var eventName = this.UpdateOnInput
                ? "oninput"
                : "onchange";
    
            builder.OpenElement(0, "input");
            builder.AddMultipleAttributes(1, this.AdditionalAttributes);
            if (!string.IsNullOrWhiteSpace(this.CssClass))
                builder.AddAttribute(2, "class", this.CssClass);
    
            builder.AddAttribute(3, "value", CurrentValueAsString);
            builder.AddAttribute(4, eventName, EventCallback.Factory.CreateBinder<string?>(this, __value => CurrentValueAsString = __value, CurrentValueAsString));
            builder.SetUpdatesAttributeName("value");
            builder.AddElementReferenceCapture(5, __inputReference => Element = __inputReference);
            builder.CloseElement();
        };
    }
    

    Personlia.razor can now look like this. Note we capture the editContext and then cast the model to PersonEditContext. ArgumentNullException raises exceptions if we get it wrong.

    @implements IDisposable
    <div class="row bg-light m-3 p-2 border border-dark">
        <div class="col-12 mb-3">
            <InputControl Label="First Name" @bind-Value=this.model.FirstName />
        </div>
        <div class="col-12">
            <InputControl Label="Last Name" @bind-Value=this.model.LastName />
        </div>
    </div>
    
    @code {
        [CascadingParameter] private EditContext editContext { get; set; } = default!;
    
        private PersonEditContext model = default!;
    
        protected override void OnInitialized()
        {
            ArgumentNullException.ThrowIfNull(editContext);
            var context = editContext.Model as PersonEditContext;
            ArgumentNullException.ThrowIfNull(context);
            model = context;
            editContext.OnFieldChanged += OnModelChanged;
        }
    
        private void OnModelChanged(object? sender, FieldChangedEventArgs e)
            => StateHasChanged();
        
        public void Dispose()
            => editContext.OnFieldChanged -= OnModelChanged;
    }
    

    Here's a simple display component:

    @implements IDisposable
    
    <div class="bg-dark text-white p-2 m-2">
        <pre>First Name: @this.model.FirstName</pre>
        <pre>Last Name: @this.model.LastName</pre>
        <pre>Edit State: @(this.model.IsDirty ? "Dirty" : "Clean")</pre>
    </div>
    
    @code {
        [CascadingParameter] private EditContext editContext { get; set; } = default!;
    
        private PersonEditContext model = default!;
    
        protected override void OnInitialized()
        {
            ArgumentNullException.ThrowIfNull(editContext);
            var context = editContext.Model as PersonEditContext;
            ArgumentNullException.ThrowIfNull(context);
            model = context;
            editContext.OnFieldChanged += OnModelChanged;
        }
    
        private void OnModelChanged(object? sender, FieldChangedEventArgs e)
            => StateHasChanged();
    
        public void Dispose()
            => editContext.OnFieldChanged -= OnModelChanged;
    }
    

    And Index edit form.

    @page "/"
    @implements IDisposable
    <PageTitle>Index</PageTitle>
    
    @inject ResumeDto ResumeDto
    
    <div class="row d-flex">
        <EditForm EditContext=this.editContext>
    
            <RecordEditContextTracker RecordEditContext=this.model />
    
            <Personalia />
    
            <div class="col-12 mb-3">
                <InputControl Label="First Name" @bind-Value=this.model.FirstName />
            </div>
    
            <PersonDisplay />
    
        </EditForm>
    
    </div>
    
    @code {
        private PersonEditContext model = new(new());
        private EditContext? editContext;
    
        protected override void OnInitialized()
        {
            editContext = new EditContext(model);
            editContext.OnFieldChanged += OnModelChanged;
        }
    
        private void OnModelChanged(object? sender, FieldChangedEventArgs e)
            => StateHasChanged();
    
        public void Dispose()
        {
            if (editContext is not null)
                editContext.OnFieldChanged -= OnModelChanged;
        }
    }
    

    You can also see in the EditContext repo how to deal with edit state for button control and form locking.

    I've temporarily published a working version of this code to this Repo.

    enter image description here