Search code examples
c#blazor

WHY Blazor component parameters should be the auto properties?


From the official Microsoft documentation:

Component parameters should be declared as auto-properties, meaning that they shouldn't contain custom logic in their get or set accessors. For example, the following StartData property is an auto-property:

[Parameter] public DateTime StartData { get; set; } 

https://learn.microsoft.com/en-us/aspnet/core/blazor/components/?view=aspnetcore-9.0

Well, this is the dogma because I don't see the justification.

I agree that there must not be the complicated logic, but there is the important case when the simple logic must be - it is the validation.

Example

The value of theme parameter of AdmonitionBlock component must be either the element of StandardThemes enumeration or the element of CustomThemes enumeration which must be defined via defineThemes method before use of AdmonitionBlock component:

public partial class AdmonitionBlock : Microsoft.AspNetCore.Components.ComponentBase
    
{

  public static string CSS_NAMESPACE = "AdmonitionBlock";

  public enum StandardThemes { regular }

  protected internal static Type? CustomThemes;

  public static void defineThemes(Type CustomThemes)
  {
    YDF_ComponentsHelper.ValidateCustomTheme(CustomThemes);
    AdmonitionBlock.CustomThemes = CustomThemes;
  }

  protected string _theme = AdmonitionBlock.StandardThemes.regular.ToString();

  [Microsoft.AspNetCore.Components.Parameter]
  public object theme
  {
    get => this._theme;
    set => YDF_ComponentsHelper.
        AssignThemeIfItIsValid<AdmonitionBlock.StandardThemes>(value, AdmonitionBlock.CustomThemes, ref this._theme);
  }

  protected internal static bool mustConsiderThemesCSS_ClassesAsCommon = YDF_ComponentsHelper.areThemesCSS_ClassesCommon;

  public static void considerThemesAsCommon()
  {
    AdmonitionBlock.mustConsiderThemesCSS_ClassesAsCommon = true;
  }

  [Microsoft.AspNetCore.Components.Parameter]
  public bool areThemesCSS_ClassesCommon { get; set; } =
      YDF_ComponentsHelper.areThemesCSS_ClassesCommon || AdmonitionBlock.mustConsiderThemesCSS_ClassesAsCommon;
      
}

The validation is being executed by the ComponentsHelper class (not only for AdmonitionBlock component):

public abstract class ComponentsHelper
{

  public static bool areThemesCSS_ClassesCommon = false;


  public static void ValidateCustomTheme(Type CustomThemes)
  {
    if (!CustomThemes.IsEnum)
    {
      throw new CustomYDF_ThemeIsNotEnumerationException();
    }
  }

  public static void AssignThemeIfItIsValid<TStandardThemes>(object value, Type? customThemes, ref string _theme)
  {

    if (value is TStandardThemes standardTheme)
    {
      _theme = $"{ standardTheme }";
      return;
    }


    string stringifiedThemeValue = value.ToString() ?? "";

    if (customThemes is not null)
    {

      Type customThemesType = customThemes;

      if (customThemesType.IsEnum && Enum.GetNames(customThemesType).Contains(stringifiedThemeValue))
      {
        _theme = stringifiedThemeValue;
        return;
      }

    }


    throw new InvalidThemeParameterForYDF_ComponentException();

  }
  
}

Update

Following the advices, turned theme to auto-property and added the SetParametersAsync method:

public partial class AdmonitionBlock : Microsoft.AspNetCore.Components.ComponentBase
{

  public enum StandardThemes { regular }

  protected internal static Type? CustomThemes;

  public static void defineThemes(Type CustomThemes)
  {
    ComponentsHelper.ValidateCustomTheme(CustomThemes);
    AdmonitionBlock.CustomThemes = CustomThemes;
  }

  [Microsoft.AspNetCore.Components.Parameter]
  public object theme { get; set; } = AdmonitionBlock.StandardThemes.regular;

  public string themeName => this.theme.ToString() ?? "";

  
  public override Task SetParametersAsync(Microsoft.AspNetCore.Components.ParameterView parameters)
  {
    
    parameters.SetParameterProperties(this);

    if (parameters.TryGetValue<object>(nameof(this.theme), out object? value))
    {
      ComponentsHelper.ValidateTheme<AdmonitionBlock.StandardThemes>(value, AdmonitionBlock.CustomThemes);
    }
    
    return base.SetParametersAsync(ParameterView.Empty);
    
  }

}

where ValidateTheme is:

public abstract class ComponentsHelper
{

  public static void ValidateTheme<TStandardThemes>(object? value, Type? customThemes)
  {

    if (value is TStandardThemes standardTheme)
    {
      return;
    }


    string stringifiedThemeValue = value?.ToString() ?? "";

    if (customThemes is not null)
    {

      Type customThemesType = customThemes;

      if (customThemesType.IsEnum && Enum.GetNames(customThemesType).Contains(stringifiedThemeValue))
      {
        return;
      }

    }


    throw new InvalidThemeParameterForYDF_ComponentException();

  }

}

Unfortunately, I have not understood will SetParametersAsync called on change of any parameter or not, however looks like it will be. If so, the above logic is not equivalent to the first example, because the validation of all parameters will be invoked on any parameters has changes that it the performance impact.


Solution

  • I have to politely disagree, though it's a reasonable initial conclusion to draw when starting out in Blazor. Components are far more complex than initial impressions.

    It isn't dogma, as explained in https://github.com/dotnet/aspnetcore/issues/26230, Jon Skeet's comment, and other sources. It's sound advice, because, in almost all circumstances, people don't understand the consequences of what they're doing. There have been many questions on here on the topic.

    Parameters get applied to the component in SetParametersAsync. If you want to apply setting logic, apply it here. It can be sync or async and it will run before any component rendering.

    Here's the basic override template:

        public override Task SetParametersAsync(ParameterView parameters)
        {
           // First apply the incoming parameters to the component properties
           parameters.SetParameterProperties(this);
    
           // put your logic here such as detecting the change in a parameter
           //  from a cached local copy
    
           // Call base to run the rest of the OnInitialized and SetParameters methods
           return base.SetParametersAsync(ParameterView.Empty);
        }
    

    You can implement your own setting logic if you wish as detailed here https://learn.microsoft.com/en-us/aspnet/core/blazor/performance?view=aspnetcore-9.0#implement-setparametersasync-manually

    Revised Answer based on Updated Question

    This now looks like you're trying to fit a round peg in a square hole.

    You're passing an object into the component and trying to validate if it's a valid enum value. If so, you set a string value [maybe used in Css?].

    Theme suggests you will be using this in more than one place. In which case you can move everything out of the component and into a separate object like this.

    This implements the Notifcation pattern using an event. I've sinmplified the validation code. Not sure why you would want to raise an exception if the value is not valid.

    public class ComponentThemeProvider
    {
        public string Theme { get; private set; } = StandardThemes.Regular.ToString();
        public event EventHandler<string>? ThemeChanged;
    
        public bool SetTheme<TTheme>(TTheme value, object? senderToken = null)
        {
            if (value is not null && typeof(TTheme).IsEnum)
            {
                this.Theme = $"{value.ToString()}";
                this.ThemeChanged?.Invoke(senderToken, this.Theme);
                return true;
            }
            return false;
        }
    }
    

    A couple of enums to test with:

    public enum StandardThemes { Regular }
    
    public enum CustomThemes { Dark, Light }
    

    Depending on the scope you can either register the provider as a Scoped Service or cascade it in a component tree.

    Here's a demo cascading the provider in a component tree.

    The ComponentThemeProvider is cascaded and is a fixed. Channges are communicated through the event.

    @page "/"
    
    <PageTitle>Home</PageTitle>
    
    <h1>Hello, world!</h1>
    
    Welcome to your new app.
    <div class="m-2">
        <button class="btn btn-dark" @onclick="this.SetCustomThme">Dark Theme</button>
        <button class="btn btn-primary" @onclick="this.ResetTheme">Reset Theme</button>
    </div>
    <CascadingValue IsFixed Value="this._themeProvider">
        <ThemeComponent>
            <div>Hello</div>
            <ThemeComponent>
                <div>Hello 2</div>
            </ThemeComponent>
        </ThemeComponent>
    </CascadingValue>
    
    @code {
        private readonly ComponentThemeProvider? _themeProvider = new();
    
        private void SetCustomThme()
        {
            _themeProvider?.SetTheme<CustomThemes>(CustomThemes.Dark);
        }
    
        private void ResetTheme()
        {
            _themeProvider?.SetTheme<StandardThemes>(StandardThemes.Regular);
        }
    }
    

    And the component:

    @implements IDisposable
    <h3>Theme Component : @this.ThemeProvider.Theme</h3>
    @this.ChildContent
    
    @code {
        [CascadingParameter] private ComponentThemeProvider ThemeProvider { get; set; } = new();
        [Parameter] public RenderFragment? ChildContent { get; set; }
    
        protected override void OnInitialized()
        {
            this.ThemeProvider.ThemeChanged += this.OnThemeChanged;
        }
    
        private void OnThemeChanged(object? sender, string theme)
        {
            this.StateHasChanged();
        }
    
        public void Dispose()
        {
            this.ThemeProvider.ThemeChanged -= this.OnThemeChanged;
        }
    }