Search code examples
c#blazorblazor-webassembly

Global state container as cascading value (blazor wasm)


In blazor wasm (v6), I have an app-wide state container (AppState.razor) as a cascading parameter.

The typical code is:

AppState.razor

<CascadingValue Value="this">
  @ChildContent
</CascadingValue>

@code {

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

  private bool _isFoo;
  public bool IsFoo {
    get {
      return _isFoo;
    }
    set {
      _isFoo = value;
      StateHasChanged();
      //await SomeAsyncMethod();     // <----------
    }
  }

  //...

}

App.razor

<AppState>
  <Router>
    ...
  </Router>
</AppState>                                                          

MyComponent.razor

<!--- markup --->

@code {
  [CascadingParameter] public AppState AppState { get; set; }

  //...
}

After a component sets the AppState.IsFoo property to update the state (and update the UI), I must call an async method (e.g. save to localstorage). But I cannot do that in a sync property setter.

I could change from a cascading parameter to an injectable service, but I prefer not to.

I may need to redesign - what is the typical approach for this use case? (I've seen code with InvokeAsync(SomeAsyncMethod) without an await but I'm wary of that.)


Solution

  • Here's a different solution that I think resolves most of the issues in your implementation. It loosely based on the way EditContext works.

    First separate out the data from the component. Note the Action delegate that is raised whenever a parameter change takes place. This is basically the StateContainer in the linked MSDocs article.

    public class SPAStateContext
    {
        private bool _darkMode;
        public bool DarkMode
        {
            get => _darkMode;
            set
            {
                if (value != _darkMode)
                {
                    _darkMode = value;
                    this.NotifyStateChanged();
                }
            }
        }
    
        public Action? StateChanged;
    
        private void NotifyStateChanged()
            => this.StateChanged?.Invoke();
    }
    

    Now the State Manager Component.

    1. We cascade the SPAStateContext not the component itself which is far safer (and cheaper).
    2. We register a fire and forget handler on StateChanged. This can be async as the invocation is fire and forget.
    @implements IDisposable
    
    <CascadingValue Value=this.data>
        @ChildContent
    </CascadingValue>
    
    @code {
        private readonly SPAStateContext data = new SPAStateContext();
    
        [Parameter] public RenderFragment? ChildContent { get; set; }
    
        protected override void OnInitialized()
            => data.StateChanged += OnStateChanged;
    
        private Action? StateChanged;
    
        // This implements the async void pattern 
        // it should only be used in specific circumstances such as here in a fire and forget event handler
        private async void OnStateChanged()
        {
            // Do your async work
            // In your case do your state management saving
            await SaveStateToLocalStorage();
        }
    
        protected override async Task OnAfterRenderAsync(bool firstRender)
        {
            if (firstRender)
                await LoadStateFromLocalStorage();
        }
    
        public void Dispose()
            => data.StateChanged -= OnStateChanged;
    }