SiStone
SiStone

Reputation: 57

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?

Upvotes: 1

Views: 844

Answers (2)

RBee
RBee

Reputation: 4965

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);
        }
    }
}

Upvotes: 0

MrC aka Shaun Curtis
MrC aka Shaun Curtis

Reputation: 30410

I know you already have an answer, but your code contains a few issues that the answer does not address.

  1. You are getting a reference to a component and then setting Parameters directly. Parameters should be have a simple {get; set;} pattern. If you change them outside the SetParametersAsync cycle, you have an inconsistency between the actual component parameter state and the Renderer's version. This will lead to unexpected UI behaviour.
  2. Your calling StateHasChanged in OnAfterRender which kicks off another render.

Here's a refactored version of your code (hopefully with close to the right application logic) that used the identity field for the row in place of capturing the buttons with @ref.

My shortened data objects:

public record PickItems(int LineNumber, string Description, int Quantity);

public class PickList
{
    public int PickListId { get; } = 1;

    private List<PickItems> _items = new()
        {
            new(1, "Squash", 10 ),
            new(2, "Bread", 16 ),
            new(3, "Oranges", 12 ),
            new(4, "Potatoes", 21 ),
       };

    public IEnumerable<PickItems> PickListLines => _items.AsEnumerable();
}

And then my version of your page. I've added a bit of button functionality - disabling etc - to show how you can do that.

@page "/"

<PageTitle>Index</PageTitle>

<MudDataGrid Items="@_pickList.PickListLines" Class="py-8 my-8">
    <Columns>
        <PropertyColumn Property="x => x.LineNumber" Title="Line" />
        <PropertyColumn Property="x => x.Description" Title="Product" />
        <PropertyColumn Property="x => x.Quantity" Title="Qty" />
        <TemplateColumn CellClass="d-flex justify-end">
            <CellTemplate>
                <MudButton Size="Size.Large"
                           Variant="@Variant.Filled"
                           StartIcon="@this.GetIcon(context.Item.LineNumber)"
                           Disabled="this.IsDisabled(context.Item.LineNumber)"
                           Color="this.GetColor(context.Item.LineNumber)"
                           OnClick="() => this.AddShortage(_pickList.PickListId, context.Item.LineNumber)">
                    Shortage
                </MudButton>
            </CellTemplate>
        </TemplateColumn>
    </Columns>
</MudDataGrid>

@code {
    private bool _processing;
    private PickList _pickList = new PickList();
    private readonly List<int> _processedLines = new();

    private Color GetColor(int line)
    {
        if (_processedLines.Contains(line))
            return Color.Primary;

        return Color.Tertiary;
    }

    private string? GetIcon(int line)
    {
        if (_processedLines.Contains(line))
            return Icons.Material.Filled.DoDisturb;

        return null;
    }

    private bool Processed(int line)
        => _processedLines.Contains(line);

    private bool IsDisabled(int line)
        => this.Processed(line) || this._processing;

    private async Task AddShortage(int PickList, int LineNumber)
    {
        if (_processedLines.Contains(LineNumber))
        {
            // deal with it
            return;
        }

        _processing = true;
        //fake the api calls
        await Task.Delay(2000);
        _processedLines.Add(LineNumber);
        _processing = false;
    }
}

Everything is now built into the normal component lifecycle logic.

Upvotes: 1

Related Questions