asp.net-coreblazor.net-8.0

Implementing and understanding a Blazor Static Server-Side Rendered Counter Page


Blazor Web App Template, selecting 'None' for the Interactive Render mode.

To begin my understanding of Static Server-Side Rendering (SSR), as it is implemented in Blazor compared to that of MVC or Razor pages, I thought I might implement the missing Counter component, similar to the interactive versions of the template.

This code seems to work fine, without query string params, cookies or JS interop for using browser storage (all approaches I considered)...

@page "/counter"

<PageTitle>Counter - BlazorSSR</PageTitle>

<h1>Counter</h1>

<p>Current count: @CurrentCount</p>

<EditForm Enhance FormName="counterForm" Model="CurrentCount" OnSubmit="IncrementCount">
    <InputNumber class="d-none" @bind-Value="CurrentCount" />
    <button class="btn btn-primary" type="submit">Click me</button>
</EditForm>

@* Thanks to Ruikai Feng's answer, this is interchangeable with the above EditForm:
<form data-enhance method="post" @formname="counterForm" @onsubmit="IncrementCount">
    <AntiforgeryToken />
    <input type="number" @bind-value="@CurrentCount" name="CurrentCount" hidden />
    <button class="btn btn-primary" type="submit">Click me</button>
</form>
*@

@code {
    [SupplyParameterFromForm]
    public int CurrentCount { get; set; }

    private void IncrementCount()
    {
        CurrentCount += 1;
    }
}

My questions:

  1. Why does this work? It appears to me the IncrementCount() method is running per the OnSubmit, and the resulting form-submission is being supplied as a parameter back to the same page, successfully incrementing the CurrentCount. Is that correct?

  2. Is this the correct and simplest approach for implementing the Counter page? Just because it works, doesn't mean it's correct.

  3. For the InputNumber, I would have preferred a standard HTML input with type="hidden", but couldn't find the correct way to bind the CurrentCount. I realize I'm not using a traditional 'model' here, and I'm just using a native int. Seemed silly to create an entire model just for a single int.

  4. Having the understanding of MVC, with POST-Redirect-Get, what's the workflow here? I'm not injecting the NavigationManager, so after the OnSubmit runs IncrementCount(), is it just reloading the same page, and somehow taking the CurrentCount from my form submission and feeding it back into itself?

  5. So what's the pattern here? If I redirected to another page, can I pass the form submission data into the other page, or do I have to stay on the current page, do something with the data, and then redirect to another page?

  6. Because this is SSR, I expect, in terms of DI, that Scoped and Transient mean the same thing. So had I injected a service to keep track of the CurrentCount, only a Singleton would work, at the expense of the Counter value being the same for everyone.

Update:

While Ruikai Feng's answer is helpful, I need to address my fundamental misunderstanding for the Blazor SSR form submission workflow, as with static server-side rendering, the workflow is quite different compared with a page having interactivity.

To that end, I created a page slightly more complex than the Counter page, albeit a simple example to demonstrate the workflow.

@page "/FormLifecycle"

<PageTitle>Form Lifecycle - BlazorSSR</PageTitle>

<div class="row">
    <div class="col-12 col-lg-6 mx-auto">
        <h1>Form Lifecycle Testing</h1>
        
        <EditForm class="mb-2" Enhance Model="Person" FormName="personForm" OnValidSubmit="SubmitPerson">
            <DataAnnotationsValidator />
            
            <div class="input-group mb-3">
                <span class="input-group-text" for="firstName">First Name</span>
                <InputText id="firstName" class="form-control" @bind-Value="Person.FirstName" />
            </div>
            <ValidationMessage For="@(() => Person.FirstName)" class="mb-3 text-danger" style="margin-top: -16px;" />

            <div class="input-group mb-3">
                <span class="input-group-text" for="lastName">Last Name</span>
                <InputText id="lastName" class="form-control" @bind-Value="Person.LastName" />
            </div>
            <ValidationMessage For="@(() => Person.LastName)" class="mb-3 text-danger" style="margin-top: -16px;" />

            <button class="btn btn-primary" type="submit">Submit</button>
        </EditForm>
        
        @if (PersonModel.IsModelValid(Person, out _))
        {
            <p>Submitted: @Person.FirstName @Person.LastName</p>
        }
    </div>
</div>

@code {
    [SupplyParameterFromForm(FormName = "personForm")]
    public PersonModel Person { get; set; } = new();

    protected override void OnInitialized()
    {
        Console.WriteLine("OnInitialized()");
    }

    protected override void OnParametersSet()
    {
        Console.WriteLine("OnParametersSet()");
    }

    protected override void OnAfterRender(bool firstRender)
    {
        Console.WriteLine("OnAfterRender()");
    }

    private void SubmitPerson()
    {
        Console.WriteLine("SubmitPerson()");

        if (PersonModel.IsModelValid(Person, out _))
        {
            Console.WriteLine($"Submitted: {Person.FirstName} {Person.LastName}");
        }
    }
}

The output is as follows:
OnInitialized()
OnParametersSet()
[I filled in the form and pressed submit]
OnInitialized()
OnParametersSet()
SubmitPerson()
Submitted: Homer Simpson

I expected the SubmitPerson() method to be run before reloading the page, not after.

Suppose I was displaying a list of people from a database on the same page as the form. It seems to me, this would reload the same page over again, then submit the new person... leaving it to me to reload the page a third time, OR fake it by manually adding the submitted person to the list of people from the database, without actually reloading the page or reloading database results.

I may have answered my own question with this little experiment, just it feels the answer is not as intuitive as I would have thought. My aim is to utilize static server-side rendering as much as possible, to avoid/minimize the need for web sockets or client-side activity, as much as possible.


Solution

  • Using Blazor static server-side rendering, here is my rendition of the Counter page:

    @page "/counter/{reset:bool?}"
    @inject NavigationManager NavMan
    
    <PageTitle>Counter</PageTitle>
    
    <h1>Counter</h1>
    
    <p>Current count: @CurrentCount</p>
    
    <form class="d-inline" data-enhance method="post" @formname="counterForm" @onsubmit="IncrementCount">
        <AntiforgeryToken />
        <input type="number" @bind-value="@CurrentCount" name="CurrentCount" hidden />
        <button class="btn btn-primary" type="submit">Click me</button>
        <a class="btn btn-secondary" href="/counter/true">Reset</a>
    </form>
    
    @code {
        [Parameter]
        public bool Reset { get; set; }
        
        [SupplyParameterFromForm(FormName = "counterForm")]
        public int CurrentCount { get; set; }
    
        protected override void OnInitialized()
        {
            if (Reset)
            {
                NavMan.NavigateTo("/counter");
            }
        }
    
        private void IncrementCount()
        {
            CurrentCount += 1;
        }
    }
    

    And answers to my own questions:

    Note 1: When you submit the form, the form data is loaded into the model annotated with [SupplyParameterFromForm].

    In the counter example, there is only an int/primitive variable, not really a model, but the framework seems to handle this scenario, hence no Model="modelName" attribute is required on form tag. In your average scenario, there would be a Model attribute specified.

    Note 1a: I've observed in some of the Microsoft samples, when there is no model, only a primitive, I've seen some folks use Model="new()", although it doesn't seem to be required.

    Note 2: If you have multiple forms on the page, thus multiple models, you should annotate each model [SupplyParameterFromForm(FormName = "xyzForm")]. Careful as SupplyParameterFromForm has a 'Name' property, which isn't the right one, it's 'FormName'.

    Note 3: After pressing submit, presuming the form/EditForm passes validation, it will populate the form data into the model AND the page will reload with the OnInitialized/Async() method.

    As remarked in the question, the flow is this:

    OnInitialized()  
    OnParametersSet()  
    [User Submits form]  
    OnInitialized()  
    OnParametersSet()  
    Method specified in On[Valid]Submit attribute will run.  
    

    This means in OnInitialized(), you must handle the scenario where your data model is already populated, i.e. take care not to re-initialize it. This is where the null-coalescing operator is helpful [??=], e.g. person ??= new(). This is because your OnInitialized needs to handle both an initial render, as well as a render for form submission. This wasn't immediately obvious to me.

    This can be observed in the examples from the Blazor documentation for .NET 8: https://learn.microsoft.com/en-us/aspnet/core/blazor/forms/?view=aspnetcore-8.0

    Note 4: As for the traditional MVC pattern of GET-POST-REDIRECT, it's simply a matter of, what do you want to do in the method specified in your On[Valid]Submit attribute? Do you want to stay on the same page, or do you want to redirect somewhere else?

    If you want to redirect somewhere else, inject the NavigationManager and use the NavigateTo() method. This way, you can take action with the form submission model data, and then redirect somewhere else, if that is your requirement.

    Note 5: Running in debug mode, you'll receive a 'NavigationException' from this line: NavMan.NavigateTo("/counter");. This is actually by design [, unfortunately]. Reference https://github.com/dotnet/aspnetcore/issues/50478 for more info.

    Note 6: On the topic redirecting, let's say you have a button on your form, or a link, which will redirect the user to another page. If you don't need to take any action other than redirecting, you need not a form for this, you can just use an anchor tag, e.g. from the Counter page, the reset button:

    <a class="btn btn-secondary" href="/counter/true">Reset</a>
    

    The OnInitialized() method of the Counter page will recognize when the Reset route parameter is populated and reload the page to effectively 'reset' the count.

    Perhaps MVC seems more intuitive, purely because it was learned before Blazor, and the idea of a Controller vs a code-behind helps to think of server-side vs view concerns separately, but it is really a matter of orienting yourself to Blazor, when working purely in static SSR, that the code section is running server-side, and the markup is the client-side view.

    Note 7: One additional item that I see common. When submitting a form, in order for the On[Valid]Submit method to run, the form must be rendered in this workflow:

    OnInitialized()  
    OnParametersSet()  
    [User Submits form]  
    OnInitialized()  
    OnParametersSet()  
    [Form being submitted must be rendered on the page]  
    Method specified in OnSubmit/OnValidSubmit runs.
    

    Essentially, if you have some conditional logic that excludes the form being submitted from rendering, then you'll get this error, "Cannot submit the form 'formName' because no form on the page currently has that name."

    This was not immediately obvious to me, due to general misunderstanding of this static server-side rendered workflow.