javascriptc#reactjsblazormudblazor

Blazor Child Component UI Not Updating on Property Change


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.


Solution

  • It turned out my code was just fine. It turned out the MudBlazor component is still in development.