Home > Net >  Global state container as cascading value (blazor wasm)
Global state container as cascading value (blazor wasm)

Time:10-15

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.)

CodePudding user response:

I figured out a workaround.

My requirements:

  • I wanted a simple state container as advocated by the docs
  • I didn't want the state container as a service (as shown in the docs), because I wanted to avoid the boilerplate of event handlers and disposal in all consuming components
  • I wanted the state container as a global cascading parameter, as it's so simple

The trick is to trigger a rerender, and then to perform async work in the OnAfterRenderAsync handler.

AppState.razor

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

@code {

  private bool _isDirty;                        // <-----

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

  protected override async Task OnAfterRenderAsync(bool firstRender) {
    await base.OnAfterRenderAsync(firstRender);
    if (firstRender) {
      await LoadStateFromLocalStorage();
    }
    if (!firstRender && _isDirty) {             // <-----
      await SaveStateToLocalStorage();
      _isDirty = false;
    }
  }

  private bool _isDarkMode;
  public bool IsDarkMode {
    get {
      return _isDarkMode;
    }
    set {
      if (value == _isDarkMode) return;
      _isDarkMode = value;
      _isDirty = true;                          // <-----
      StateHasChanged();
    }
  }

  //other properties...

  private async Task LoadStateFromLocalStorage() {
    Console.WriteLine("LOADED!");
    await Task.CompletedTask;
  }

  private async Task SaveStateToLocalStorage() {
    Console.WriteLine("SAVED!");
    await Task.CompletedTask;
  }

}

The example async code above is for loading / saving to localstorage, but one could do any async work in the same way.

Notes

  • It could be that concurrency issues would cause rerenders and async invocations to occur in an unexpected order. But it should be fine for most use cases, and is good enough for me. If you want something more robust with a guaranteed order, then use flux (which is overkill for something so simple).
  • This is meant for very simple app-wide stuff. I wouldn't put all my state in there. For most components I'd use other cascading parameters and injectable services.

CodePudding user response:

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;
}
  • Related