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:
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?
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}";
}
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.