blazor

CascadingParameter and OnParametersSet


I have a simple question I can't find a clear answer to.

Currently, since I have a user 'state' model, I use Cascading Parameters to my child components and update them when the state model requires it using something like this:

private async void OnClientStateChanged(object? sender, PropertyChangedEventArgs e)
{
    if (e.PropertyName == "propertyChanged" && sender is ClientState state)
    {
        _needsReselect = true;
        await InitScreen();
    }
}

I would rather use an OnParametersSet method that gets called when the cascading parameter of my user state is modified so my question is:

Question: Does a change to a Cascading Parameter fire the OnParametersSet event? In my testing I can not get it to do so.

How I solved in meantime:

protected override void OnAfterRender(bool firstRender)
{
    if (firstRender)
    {
        if (OperatingSystem.IsBrowser())
        {
            clientStates!.PropertyChanged -= OnClientStateChanged;
            clientStates!.PropertyChanged += OnClientStateChanged;
        }
    }
}

private async void OnClientStateChanged(object? sender, PropertyChangedEventArgs e)
{
    if (e.PropertyName == "SelectedViewLevel" && sender is ClientStates state)
    {
        await InitScreen();
    }
}

Solution

  • Important points

    1. A UI event in any ComponentBase component will trigger a call to StateHasChanged on the first async yield and at the completion of the handler.

    2. Components have no inbuilt mechanism to detect a change in state. You need to do that manually.

    3. The renderer will call SetParametersAsync on any child component where it detects a state change in the child's parameters. With objects it can't detect a change, so it will always call SetParametersAsync.

    4. You must call StateHasChanged on normal events to force a component render event.

    Demo Code

        public class CounterState
        {
            public int Value { get; private set; }
            public event EventHandler<int>? ValueChanged;
    
            public void Increment()
            {
                this.Value++;
                this.ValueChanged?.Invoke(this, this.Value);
            }
        }
    

    CounterComponent

    <div class="m-2 p-2 border border-1">
    
        <p role="status">Current count: @this.State?.Value</p>
    
        <button class="btn btn-primary" @onclick="IncrementCount">Click me</button>
    
        @this.ChildContent
    
    </div>
    
    @code {
        [Parameter] public RenderFragment? ChildContent { get; set; }
        [CascadingParameter] private CounterState? State { get; set; }
    
        private void IncrementCount()
        {
            this.State?.Increment();
        }
    }
    

    BlockComponent

    <div class="m-2 p-2 border border-1">
        @this.ChildContent
    
    </div>
    
    @code {
        [Parameter] public RenderFragment? ChildContent { get; set; }
    }
    

    Counter

    @page "/counter"
    
    <PageTitle>Counter</PageTitle>
    
    <h1>Counter</h1>
    
    <p role="status">Current count: @_state.Value</p>
    
    <button class="btn btn-primary" @onclick="IncrementCount">Click me</button>
    
    <CascadingValue Value="_state" >
    
        <CounterComponent>
            <CounterComponent>
                <BlockComponent>
                    <CounterComponent />
                </BlockComponent>
                </CounterComponent>
        </CounterComponent>
    
    </CascadingValue>
    
    @code {
        private readonly CounterState _state = new();
    
        private void IncrementCount()
        {
            _state.Increment();
        }
    }
    

    Explanation

    1. Click Page button. A UI event so we get a component refresh. When the Renderer processes the render request it checks the state of its direct child parameters. It calls SetParametersAsync on any where it detects a parameter state change.

    As this component is the the owner of the cascade, it also calls SetParametersAsync on all components that use it.

    1. Click on the second button down. A UI event so the component gets refreshed. The child has object parameters, so it gets refreshed regardless.

    The cascade stops at the BlockComponent because there's no state change on that component. The CounterComponent within the BlockComponent block doesn't get refreshed.

    Example result on clicking the second button down.

    enter image description here

    One solution is to use an event in the State object and wire up the components that need to refresh to the event.

    CounterComponent could look something like this:

    @implements IDisposable
    <div class="m-2 p-2 border border-1">
    
        <p role="status">Current count: @this.State?.Value</p>
    
        <button class="btn btn-primary" @onclick="IncrementCount">Click me</button>
    
        @this.ChildContent
    
    </div>
    
    @code {
        [Parameter] public RenderFragment? ChildContent { get; set; }
        [CascadingParameter] private CounterState? State { get; set; }
        private Guid _token = Guid.Empty;
    
        protected override void OnInitialized()
        {
            if (this.State is not null)
                this.State.ValueChanged += this.OnStateChanged;
        }
    
        private void OnStateChanged(object? sender, Guid token)
        {
            if (token != _token)
                this.StateHasChanged();
        }
    
        private void IncrementCount()
        {
            _token = Guid.NewGuid();
            this.State?.Increment(_token);
        }
    
        public void Dispose()
        {
            if (this.State is not null)
            this.State.ValueChanged -= this.OnStateChanged;
        }
    }
    

    And:

        public class CounterState
        {
            public int Value { get; private set; }
            public event EventHandler<Guid>? ValueChanged;
    
            public void Increment()
            {
                this.Value++;
                this.ValueChanged?.Invoke(this, Guid.Empty);
            }
    
            public void Increment(Guid token)
            {
                this.Value++;
                this.ValueChanged?.Invoke(this, token);
            }
        }
    

    And Counter something like this. Note that the cascade is now fixed.

            <CascadingValue IsFixed Value="_state">
    
                <CounterComponent>
                    <CounterComponent>
                        <BlockComponent>
                            <CounterComponent />
                        </BlockComponent>
                    </CounterComponent>
                </CounterComponent>
            </CascadingValue>
    
    @code {
        private readonly CounterState _state = new();
        private Guid _token = Guid.Empty;
    
        protected override void OnInitialized()
        {
            _state.ValueChanged += this.OnStateChanged;
        }
    
        private void IncrementCount()
        {
            _token = Guid.NewGuid();
            _state.Increment();
        }
    
        private void OnStateChanged(object? sender, Guid token)
        {
            if (token != _token)
                 this.StateHasChanged();
        }
    }