I am new to Blazor, but I have a strong background in React. I suspect I might be trying to make Blazor work like React, and that could be causing my issue.
I have a BaseStep component that provides a structure for child steps in a multi-step form. One such step is StepOne, which contains a ToggleGroup component that allows users to select an engagement type.
However, the UI does not update/re-render when @AccessRequest.NatureOfEngagementId changes. Strangely enough:
The <p>
tag correctly prints the updated ID, meaning the data itself is changing.The selection in ToggleGroup only updates when another part of the page causes a re-render.This leaves me thinking the issue lies in my ToggleGroup component. I need help figuring out why my UI is not updating immediately when @AccessRequest.NatureOfEngagementId changes.
I have a BaseStep
component that acts as an abstract step:
public abstract class StepBase : ComponentBase { [Parameter] public Request NewAccessRequest { get; set; } = default!; [Parameter] public EventCallback<Request> NewAccessRequestChanged { get; set; } = default!; [Parameter] public EventCallback<bool> OnStepValidChanged { get; set; } = default!; public required MudForm StepForm; protected bool StepSuccess; // Local copy of the request for binding private Request _localRequest = default!; // Property that binds to the local request and updates parent when changed protected Request AccessRequest { get => NewAccessRequest; set { if (!EqualityComparer<Request>.Default.Equals(NewAccessRequest, value)) { NewAccessRequestChanged.InvokeAsync(value); } } } // Ensure local copy is in sync when parent updates the parameter protected override void OnParametersSet() { if (!EqualityComparer<Request>.Default.Equals(NewAccessRequest, _localRequest)) { _localRequest = NewAccessRequest; } } // Method to validate the step public async Task<bool> ValidateStep() { if (StepForm != null) { await StepForm.Validate(); await OnStepValidChanged.InvokeAsync(StepSuccess); } return StepSuccess; }}
This child component inherits from StepBase
and updates AccessRequest
when an engagement is changed.
public partial class StepOne : StepBase { [Parameter] public List<NatureOfEngagement> Engagements { get; set; } = []; private void OnEngagementChanged(int newEngagementId) { AccessRequest = RequestBuilder.From(AccessRequest) .WithNatureOfEngagementId(newEngagementId) .Build(); }}
Child Component's UI:
@inherits StepBase<MudStep Title="What relationship does the person requesting access have with the company"><MudForm Model="@NewAccessRequest" @ref="StepForm" @bind-IsValid="StepSuccess"><MudGrid><MudItem xs="12"><ToggleGroup T="int" Required RequiredError="Please select an option" Value="@AccessRequest.NatureOfEngagementId" ValueChanged="OnEngagementChanged" SelectionMode="SelectionMode.SingleSelection" Options="Engagements.Select(e => new Option<int> { Value = e.Id, Label = e.Engagement }).ToList()" /></MudItem><p>Selected ID @AccessRequest.NatureOfEngagementId</p></MudGrid></MudForm></MudStep>
This custom ToggleGroup<T>
component is used for selection:
public partial class ToggleGroup<T> : MudFormComponent<T, T> { // The text to display above the toggle group [Parameter] public string? Label { get; set; } [Parameter] public Dictionary<string, object>? AdditionalAttributes { get; set; } // The list of toggle options [Parameter] public IEnumerable<Option<T>>? Options { get; set; } [Parameter] public SelectionMode SelectionMode { get; set; } = SelectionMode.SingleSelection; [Parameter] [Category(CategoryTypes.List.Behavior)] public EventCallback<T?> ValueChanged { get; set; } [Parameter] [Category(CategoryTypes.List.Behavior)] public EventCallback<IEnumerable<T?>?> ValuesChanged { get; set; } [Parameter] public T? Value { get; set; } [Parameter] public IEnumerable<T>? Values { get; set; } // Single selection binding private T? SingleSelectedValue { get => Value; set { if (!EqualityComparer<T?>.Default.Equals(_value, value)) { _value = value; Value = value; Touched = true; BeginValidateAsync(); ValueChanged.InvokeAsync(value); } } } // Multiple selection binding private IEnumerable<T>? MultiSelectedValues { get => Values; set { if (!EqualityComparer<IEnumerable<T>?>.Default.Equals(_value as IEnumerable<T>, value)) { _value = value != null ? (T?)(object?)value : default; Touched = true; Values = value; ValuesChanged.InvokeAsync(value); BeginValidateAsync(); } } } public ToggleGroup() : base(converter: new MudBlazor.Converter<T, T>()) { } protected override Task ValidateValue() { var errors = new List<string>(); ValidationErrors.Clear(); if (Options == null || !Options.Any()) { // If there are no available options, mark as error Error = Required; } else { if (SelectionMode == SelectionMode.SingleSelection) { // Ensure SingleSelectedValue is present in Options Error = Required && (SingleSelectedValue == null || !Options.Any(o => EqualityComparer<T>.Default.Equals(o.Value, SingleSelectedValue))); } else { // Ensure all MultiSelectedValues exist in Options Error = Required && (MultiSelectedValues == null || !MultiSelectedValues.All(val => Options.Any(o => EqualityComparer<T>.Default.Equals(o.Value, val)))); } } if (Error) { ValidationErrors.Add(RequiredError); } ValidationErrors = errors; return Task.CompletedTask; } }
Toggle Component UI:
@typeparam T @inherits MudFormComponent<T, T><MudStack> @if (Label != null) {<MudText Class="m-2" Typo="Typo.body2" Color="@(Error ? Color.Error : Color.Default)"> @(Label + (Required ? "*" : ""))</MudText> } @if (SelectionMode == SelectionMode.SingleSelection) {<!-- Single Selection --><MudToggleGroup T="T" CheckMark SelectionMode="SelectionMode.SingleSelection" Color="@(Error ? Color.Error: Color.Primary)" @bind-Value="SingleSelectedValue" @attributes="@AdditionalAttributes"> @foreach (var option in Options ?? Enumerable.Empty<Option<T>>()) {<MudToggleItem Value="@option.Value" UnselectedIcon="@Icons.Material.Filled.CheckBoxOutlineBlank" SelectedIcon="@Icons.Material.Filled.CheckBox"> @option.Label</MudToggleItem> }</MudToggleGroup> } else {<!-- Multiple Selection --><MudToggleGroup T="T" CheckMark SelectionMode="SelectionMode.MultiSelection" Color="@(Error ? Color.Error: Color.Primary)" @bind-Values="MultiSelectedValues" @attributes="@AdditionalAttributes"> @foreach (var option in Options ?? Enumerable.Empty<Option<T>>()) {<MudToggleItem Value="@option.Value" UnselectedIcon="@Icons.Material.Filled.CheckBoxOutlineBlank" SelectedIcon="@Icons.Material.Filled.CheckBox"> @option.Label</MudToggleItem> }</MudToggleGroup> } @if (Error) {<MudText Class="text-danger" Style="margin-top: -10px; margin-left: 8px;" Typo="Typo.caption"> @(string.IsNullOrWhiteSpace(ErrorText) ? RequiredError : ErrorText)</MudText> }</MudStack>
The issue is that the child component's UI does not re-render when the @AccessRequest.NatureOfEngagementId
property changes. The correct ID is printed inside the <p>
tag, but the ToggleGroup does not visually update unless another re-render is triggered elsewhere.
I have been on this for sometime now and would love if another eye was to look at this maybe they can spot the issue.