Is there any way to disallow a component from rendering using `ShouldRender`, yet allow its children to rerender as they see fit?

Viewed 853

If a component has a child component that takes a reference type Foo as a parameter, then any HTML event completion will cause the child to rerender: in the below Index.razor, clicking the button will cause the BarPanel to re-render.

<BarPanel InputFoo="Foo1" />
<button @onclick="()=>{}" />

Now if BarPanel is onerous to render and/or present many times, I might choose to override its ShouldRender to reduce this re-render frequency.

But suppose further BarPanel has a child QuxPanel, and it also takes an InputFoo--in BarPanel.razor:

<span>@InputFoo.Bar</span> 
<QuxPanel InputFoo="InputFoo" />
@code { 
   [Parameter] Foo InputFoo {get;set;}
   protected override bool ShouldRender() {
      ...
   }
}

And of course in QuxPanel.razor:

<span>@InputFoo.Qux</span>
@code { 
   [Parameter] Foo InputFoo {get;set;}
}

So on a UI event completion in Index, I'd like to permit a re-render of QuxPanel nested inside BarPanel, but it seems that I can't unless BarPanel knows about it and re-renders first. But BarPanel is the expensive component, and I'd like to rerender its children while preventing it from re-rendering (for example, when Qux changes but Bar remains unchanged). Is there any way to disallow a component from rendering using ShouldRender, yet allow its children to rerender as they see fit?

1 Answers

In general you shouldn't worry. The Renderer diffing engine works out what has changed in the DOM and only dispatches the changes to the browser. You should have a very good understanding of the whole rendering process before selectively rendering components.

However, if you want to go down that route, you need to drive rendering through events. Here's some demo code that also monitors SetParameter and Render events in components.

BaseService

A very basic notification Service. Make sure you register it as a Scoped Service.

    public class BaseService
    {
        public void NotifyComponentANeedstoRender()
            => ReRenderComponentA?.Invoke(this, EventArgs.Empty);

        public event EventHandler ReRenderComponentA;
    }

BaseControl

@implements IDisposable
<div class="border-1 m-1 p-1">
    <div class="bg-dark text-light">
        <span>
            Sets: @_sets
        </span>
        <span>
            Renders: @_renders
        </span>
    </div>
    <div class="p-1">
            @this.ChildContent
    </div>
</div>

@code {

    private int _renders = 1;
    private int _sets = 0;

    [Parameter] public RenderFragment ChildContent { get; set; }

    [Parameter] public bool ShouldNotRender { get; set; }

    [Parameter] public bool EventDriven { get; set; }

    [Inject] BaseService baseService { get; set; }

    protected override void OnInitialized()
    {
        if (EventDriven)
            baseService.ReRenderComponentA += OnRender;
    }

    public override Task SetParametersAsync(ParameterView parameters)
    {
        parameters.SetParameterProperties(this);
        _sets++;
        return base.SetParametersAsync(ParameterView.Empty);
    }

    protected override bool ShouldRender()
    {
        if (this.ShouldNotRender)
            return false;
        _renders++;
        return true;
    }

    private void OnRender(object sender, EventArgs e)
    => InvokeAsync(this.StateHasChanged);


    public void Dispose()
    {
        if (EventDriven)
            baseService.ReRenderComponentA -= OnRender;
    }

}

TestPage

Finally a demo razor page.

@page "/TestRender"
<BaseControl>
    <button class="btn btn-info me-2" @onclick="OnClickAsync">Increment Async</button>
    <button class="btn btn-primary" @onclick="OnClick">Increment</button>
    <BaseControl ShouldNotRender="true">
        <BaseControl>
            <BaseControl>
                Not the correct Clicks after intial render : @_clicks
            </BaseControl>
            <BaseControl EventDriven="true">
                <div>
                    The correct Clicks : @_clicks
                </div>
            </BaseControl>
        </BaseControl>
    </BaseControl>
    <BaseControl>
        <BaseControl>
            <BaseControl>
                The correct Clicks : @_clicks
            </BaseControl>
        </BaseControl>
    </BaseControl>
</BaseControl>

@code {

    [Inject] BaseService baseService { get; set; }

    private string _content => $"<div>{_clicks}</div>";

    private int _clicks;

    private async Task OnClickAsync()
    {
        _clicks++;
        this.baseService.NotifyComponentANeedstoRender();
        await Task.Delay(1);
    }

    private void OnClick()
    {
        _clicks++;
        this.baseService.NotifyComponentANeedstoRender();
    }
}

Updates to the Answer

The only method the Renderer has to communicate with a Component is SetParametersAsync. The Renderer has no method of directly running a re-render. In ComponentBase, SetParametersAsync calls the OnInitialized/OnParametersSet method set and as part of that sequence calls StateHasChanged on one or more occasions.

When a render event occurs in a component, the Renderer needs to decide what's changed, and which child components need to know about the changes. If it decides there's a change in one or more of a component's parameters, it calls SetParametersAsync on that component. It's up to the component to decide if it needs to re-render.

Let's look at a couple of examples:

... component code
<BaseControl>
    Hello World
</BaseControl>
... component event handlers

At first glance you would think that BaseControl shouldn't update when a render event occurs in the parent - there's no parameters to change. However, that isn't actually the case. That "Hello World" gets compiled into a RenderFragment that gets passed to BaseControl as the ChildContent Parameter. The RenderFragment is a delegate that the Renderer knows could have changed (it has no way of knowing if it has or hasn't), so it calls SetParametersAsync on BaseControl.

Example 2 looks like this:

... component code
<BaseControl/>
... component event handlers

Now BaseControl has no ChildContent, so the Renderer knows it's parameters haven't changed - ChildContent is still null - so it doesn't call SetParamatersAsync.

The same situation applies to objects and primitives. The Renderer doesn't know if an object has changed - so it assumes it has - and calls SetParamatersAsync.

Here's a couple of components and an updated test page to demo the differences.

PrimitiveControl

<div class="border-1 m-1 p-1">
    <div class="bg-dark text-light">
        <span>
            Sets: @_sets
        </span>
        <span>
            Renders: @_renders
        </span>
    </div>
    <div class="p-1">
        <div>Value : @Value</div>
    </div>
</div>

@code {
    [Parameter] public int Value { get; set; }

    private int _renders = 1;
    private int _sets = 0;

    public override Task SetParametersAsync(ParameterView parameters)
    {
        parameters.SetParameterProperties(this);
        _sets++;
        return base.SetParametersAsync(ParameterView.Empty);
    }

    protected override bool ShouldRender()
    {
        _renders++;
        return true;
    }
}

ObjectControl

<div class="border-1 m-1 p-1">
    <div class="bg-dark text-light">
        <span>
            Sets: @_sets
        </span>
        <span>
            Renders: @_renders
        </span>
    </div>
    <div class="p-1">
        @foreach (var str in Value)
        {
            <div>@str</div>
        }
    </div>
</div>

@code {

    [Parameter] public List<string> Value { get; set; }

    private int _renders = 1;
    private int _sets = 0;

    public override Task SetParametersAsync(ParameterView parameters)
    {
        parameters.SetParameterProperties(this);
        _sets++;
        return base.SetParametersAsync(ParameterView.Empty);
    }

    protected override bool ShouldRender()
    {
        _renders++;
        return true;
    }
}

Updated Test Page

@page "/TestRender"
<BaseControl>
    <button class="btn btn-info me-2" @onclick="OnClickAsync">Increment Async</button>
    <button class="btn btn-primary" @onclick="OnClick">Increment</button>
    <BaseControl ShouldNotRender="true">
        <BaseControl>
            <BaseControl>
                Not the correct Clicks after intial render : @_clicks
            </BaseControl>
            <BaseControl EventDriven="true">
                <div>
                    The correct Clicks : @_clicks
                </div>
            </BaseControl>
        </BaseControl>
    </BaseControl>
    <BaseControl>
        <BaseControl>
            <BaseControl>
                The correct Clicks : @_clicks
            </BaseControl>
        </BaseControl>
    </BaseControl>
</BaseControl>
<BaseControl>
    Hello World
</BaseControl>
<BaseControl/>
<ObjectControl Value="list" />
<PrimitiveControl Value="intvalue" />

@code {

    [Inject] BaseService baseService { get; set; }

    private string _content => $"<div>{_clicks}</div>";

    private int _clicks;

    private List<string> list = new List<string> { "Lagos", "Houston" };

    private int intvalue = 20;

    private async Task OnClickAsync()
    {
        _clicks++;
        this.baseService.NotifyComponentANeedstoRender();
        await Task.Delay(1);
    }

    private void OnClick()
    {
        _clicks++;
        this.baseService.NotifyComponentANeedstoRender();
    }
}
Related