Disti
Disti

Reputation: 1513

Blazor: value updated by component - page not updated

I'm facing a strange behaviour in Blazor, and I'd like to understand exactly what is going on.

I have this page:

@page "/"

<div>IsSet: @myObj.IsSet</div>

<MyComponent Param1="myObj" OnSet="OnSetHandler"></MyComponent>

@code
{
    private MyClass myObj = new();

    private void OnSetHandler()
    {
        /* this is actually an empty function */
    }
}

This is MyComponent:

<hr />

<h6>MyComponent</h6>

<div>IsSet: @Param1.IsSet</div>
<button type="button" @onclick="SetVal">Set value</button>

<hr />

@code {

    [Parameter] public MyClass Param1 { get; set; }
    [Parameter] public EventCallback OnSet { get; set; }

    private async Task SetVal()
    {
        Param1.IsSet = true;

        if (OnSet.HasDelegate) await OnSet.InvokeAsync();
    }

}

and MyClass:

public class MyClass
{
    public bool IsSet { get; set; }
}

In this case the component instance has an handler for OnSet, and the page behaves as expected - this is the page when I click on the button:

IsSet: True
----------------------------------------
MyComponent
IsSet: True
----------------------------------------

[ Set value ]

As soon as I remove the OnSet handler in the page (<MyComponent Param1="myObj"></MyComponent>), this is what happens when I click the button:

IsSet: False
----------------------------------------
MyComponent
IsSet: True
----------------------------------------

This seems strange to me because the handler, when specified, is actually an empty function.

I also tried to add StateHasChanged in the component:

private async Task SetVal()
{
    Param1.IsSet = true;

    if (OnSet.HasDelegate) await OnSet.InvokeAsync();

    StateHasChanged();
}

but this has no effect.

This also happens if I specity OnSet="default(Action)".

Upvotes: 1

Views: 793

Answers (3)

MrC aka Shaun Curtis
MrC aka Shaun Curtis

Reputation: 30440

This answer is intended to add more detail to the other two answers.

All Razor Components inherit from ComponentBase by default.

ComponentBase implements the IHandleEvent interface which defines a custom event handler that the Renderer calls for all UI events associated with the component.

The implementation looks like this.

Task IHandleEvent.HandleEventAsync(EventCallbackWorkItem callback, object? arg)
{
    var task = callback.InvokeAsync(arg);
    var shouldAwaitTask = task.Status != TaskStatus.RanToCompletion &&
        task.Status != TaskStatus.Canceled;

    // After each event, we synchronously re-render (unless !ShouldRender())
    // This just saves the developer the trouble of putting "StateHasChanged();"
    // at the end of every event callback.
    StateHasChanged();

    return shouldAwaitTask ?
        CallStateHasChangedOnAsyncCompletion(task) :
        Task.CompletedTask;
}

// Broken out as used elsewhere in the component lifecycle
private async Task CallStateHasChangedOnAsyncCompletion(Task task)
{
    try
    {
        await task;
    }
    catch // avoiding exception filters for AOT runtime support
    {
        // Ignore exceptions from task cancellations, but don't bother issuing a state change.
        if (task.IsCanceled)
            return;

        throw;
    }
    StateHasChanged();
}

All UI events driven by the Renderer are handled by the handler. All non UI events, such as those raised by event handlers and normal Action and Func callbacks are invoked directly.

Try adding this to your component to see what happens.

@implements IHandleEvent

//...
@code {
    public async Task HandleEventAsync(EventCallbackWorkItem callback, object? arg)
    {
        await callback.InvokeAsync(arg);
        // StateHasChanged();
    }
}

Upvotes: 0

Liero
Liero

Reputation: 27370

In short, the containing component doesn't rerender automatically, when something happens in a child component.

This is expected and documented behaviour, see the blazor docs -> components -> rendering

Component rerenders when:

  • After applying an updated set of parameters from a parent component.
  • After applying an updated value for a cascading parameter.
  • After notification of an event and invoking one of its own event handlers.
  • After a call to its own StateHasChanged method

In your case, when you click MyComponent's button, the event handler rerenders MyComponent. The containing page is rerendered only when there is the event handler for MyComponent.OnSet.


The problem with this approach is that one should take care of every callback provided by the component. I hoped to find a way to automatically notify the containing page of the change.

There are several ways of notifying the containing page.

  1. You can create one universal EventCallback in the child component, e.g. OnChange, which would be called each time anything has changed

  2. Your MyClass can implement INotifyPropertyChanged and your containing page can call StateHasChanged() manually in PropertyChanged event handler

  3. You can call EditContext.NotifyFieldChanged() in child component and StateHasChanged() manually in EditContext.OnFieldChanged eventhandler in Containing page


A side note:

Two way binding like @bind-SomeProperty="..." does update the containing component because it creates the event handler for you. It is just syntactic sugar.

Upvotes: 3

Henk Holterman
Henk Holterman

Reputation: 273804

I'd like to understand exactly what is going on

You need a StatehasChanged() to happen on the page. Attaching an (empty) method to the EventCallback will do that.

I also tried to add StateHasChanged in the component:

The component is already re-rendered because of the @onclick, but that doesn't cascade 'up' to the parent.

The empty method is an acceptable workaround.
The shortest form is OnSet="() => {}"

Upvotes: 2

Related Questions