Search code examples
blazorblazor-server-sidemudblazor

MudBlazor - Unable to change UI properties when initially set in OnAfterRenderAsync


What I am trying to achieve is quite simple. I want to be able to click a button, button goes off and does something, once the something is complete, the button changes colour. Complication arises because the button this applies to is in a MudDataGrid row. Thanks to another SO post, I have the following:

    <MudDataGrid Items="@PickList.PickListLines" Class="py-8 my-8">
        <Columns>
            <PropertyColumn Property="x => x.SalesOrderRef" Title="Sales Order"/>
            <PropertyColumn Property="x => x.LineNumber" Title="Line"/>
            <PropertyColumn Property="x => x.ProductCode" Title="Product" />
            <PropertyColumn Property="x => x.Quantity" Title="Qty" />
            <PropertyColumn Property="x => x.ItemType" Title="Type"/>
            <TemplateColumn CellClass="d-flex justify-end">
                <CellTemplate>
                    <MudButton @ref="_buttonRefs[context.Item.LineNumber]" Size="Size.Large" Variant="@Variant.Filled"  Class="@context.Item.LineNumber" OnClick="@(x => AddShortage(PickList.PickListId, context.Item.LineNumber))">Shortage</MudButton>
                </CellTemplate>
            </TemplateColumn>
        </Columns>
    </MudDataGrid>

Then, in my code-behind, this:

    [Parameter]
public string PickListNumber { get; set; }
[Inject]
IInternalApiDataService InternalApiDataService { get; set; }

Dictionary<string, MudButton> _buttonRefs = new Dictionary<string, MudButton>();

public PickList PickList { get; set; } = new PickList();

protected async override Task OnInitializedAsync()
{
    PickList = await InternalApiDataService.GetPickListAsync(PickListNumber);
}

protected override async Task OnAfterRenderAsync(bool firstRender)
{
    if (firstRender == false)
    {
        foreach (KeyValuePair<string, MudButton> b in _buttonRefs)
        {
            b.Value.Color = Color.Primary;
            b.Value.StartIcon = Icons.Material.Filled.DoNotDisturb;
        }

    }

    StateHasChanged();

}

public async void AddShortage(string pickListNumber, string pickListLine)
{
    var x = await InternalApiDataService.RegisterShortageAsync(pickListNumber, Int32.Parse(pickListLine));

    var btn = _buttonRefs.First(x => x.Key == pickListLine).Value;

    btn.Color = Color.Tertiary;
    btn.StartIcon = Icons.Material.Filled.Check;

    StateHasChanged();
}

(I also had another version of the _buttonRefs.First which used TryGetValue which I have replaced). Here is my issue. If I comment out the code inside the OnAfterRenderAsync override, my button correctly changes colour when clicked. However with the OnAfterRender method in there, my buttons are correctly rendered in the desired colour and with the desired icon, but they no longer change when the OnClick handler fires...

I am sure this is a lifecycle issue, but I cannot figure out why. I have also tried removing the 'if(firstRender == false)' check to see if this changes things but it doesn't.

Interestingly, I notice when debugging that the OnAfterRenderAsync actually fires when the _buttonRefs dictionary is not yet initialised, which was also unexpected.

How do I need to structure this so that I can apply the colour and icon to the buttons, which is still correctly changed by the code in the OnClick event handler?


Solution

  • You shouldn't call StateHasChanged inside OnAfterRenderAsync. This will cause a infinite render loop because OnAfterRenderAsync lifecycle event will be called every time any state changes & anytime StateHasChanged is called. It should only be called in that lifecycle method if it's inside a controlled statement, e.g. inside an if(firstRender) block.


    Edit: The following solution is not recommend

    As pointed out by @MrC in the comments, directly modifying the parameter of a component using it's @ref is bad because it bypasses the components Paramter setting process using ParameterView & SetParametersAsync lifecycle event, probably more reasons but just this should be reason enough to not use this approach.

    I recommend using @MrC's answer


    The problem here is that we only want to set the buttons to a default value once however Dictionary<int, MudButton> _buttonRefs will not be rendered in the first render of OnAfterRenderAsync it will have to be in the subsequent renders.

    So we can introduce a local boolean to track when the buttons are rendered inside the OnAfterRenderAsync lifecycle. While making sure the StateHasChanged call inside the OnAfterRenderAsync method is controlled to only be called once.

    Here's a demo MudBlazor snippet

    Note I've intentionally made the demo verbose and added a _renderCount field just for debugging and is not required for this to work.

    Dictionary<int, MudButton> _buttonRefs = new Dictionary<int, MudButton>();
    bool _buttonsRendered = false;
    int _renderCount = 0;
    
    protected override async Task OnAfterRenderAsync(bool firstRender)
    {
        if (_buttonsRendered == false)
        {
            _renderCount++;
            if(_buttonRefs.Count>0)
            {
                Console.WriteLine($"Buttons rendered after:{_renderCount} render cycles");
                foreach (var b in _buttonRefs)
                {
                    b.Value.Color = Color.Primary;
                    b.Value.StartIcon = Icons.Material.Filled.DoNotDisturb;
                }
                _buttonsRendered = true;
                await InvokeAsync(StateHasChanged);
            }
        }
    }