DEV Community

Eric King
Eric King

Posted on • Updated on

Advanced Blazor State Management Using Fluxor, part 5 - ASP.NET EditForm Binding

This is the fifth in a short series of blog posts where I will go beyond the introductory level and dig a bit deeper into using the Fluxor library in a Blazor Wasm project.

EditForm Databinding

So far we've spent a lot of time taking advantage of the unidirectional data flow inherent in the Flux pattern.

Alt Text

But one of the major benefits of using Blazor is being able to take advantage of your C# classes and .NET functionality in the browser as well as on the server.

A specific example is using an AspNetCore EditForm, binding it a Model class, and validating it automatically, both in the browser and on the server.

EditForm, however, is based on two-way databinding to its Model. Binding the form fields directly to a read-only State object would not work.

To illustrate, let's create a UserFeedback feature in our application. Consider the following requirement:

A user can submit a feedback form, with three fields: Email Address, Rating (1 through 10), and a Comment. Upon successful submit, hide the form and display a success message.

It would be tempting to begin by creating a UserFeedbackState record with those properties:

public record UserFeedbackState 
{
    public string EmailAddress { get; init; }
    public int Rating { get; init; }
    public string Comment { get; init; }
}
Enter fullscreen mode Exit fullscreen mode

But it wouldn't take long to realize that the read-only nature of the record with init methods in place of set makes two-way binding to the EditForm impossible.

Instead, we need a traditional model class, where we can take advantage of DataAnnotations. We'll create this model and place it in the Shared project so that it can be referenced by the Blazor front-end and also by the server ApiController:

using System.ComponentModel.DataAnnotations;

namespace BlazorWithFluxor.Shared
{
    public class UserFeedbackModel
    {
        [EmailAddress]
        [Required]
        [Display(Name = "Email Address")]
        public string EmailAddress { get; set; }

        [Required]
        public int Rating { get; set; }

        [MaxLength(100)]
        public string Comment { get; set; }

        public UserFeedbackModel()
        {
            EmailAddress = string.Empty;
            Rating = 1;
            Comment = string.Empty;
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

Back in the Client project, let's put in place the folder structure \Features\UserFeedback with subfolders for \Pages and \Store.

Create a UserFeedbackStore.cs file in the \Store folder, and begin with a UserFeedbackState and its Feature:

public record UserFeedbackState 
{
    public bool Submitting { get; init; }
    public bool Submitted { get; init; }
    public string ErrorMessage { get; init; }
    public UserFeedbackModel Model { get; init; }
}

public class UserFeedbackFeature : Feature<UserFeedbackState>
{
    public override string GetName() => "UserFeedback";

    protected override UserFeedbackState GetInitialState()
    {
        return new UserFeedbackState 
        {
            Submitting = false,
            Submitted = false,
            ErrorMessage = string.Empty,
            Model = new UserFeedbackModel()
        };
    }
}
Enter fullscreen mode Exit fullscreen mode

I've added a few properties to the UserFeedbackState to represent the state of the component: Submitting, Submitted, ErrorMessage. These represent the state of the form, but not the values in the form.

The values in the form will be databound to the Model property, where I'm cheating compromising a bit by using an init-only object but with read/write properties.

Note: I'm certain that this technique isn't strictly adherent to the "immutable state" approach of Flux, since technically some state is being mutated without going through a reducer. But I think this specific and limited situation is an acceptable exception to the rule, given the benefits.

For Actions, we only need a couple:

public class UserFeedbackSubmitSuccessAction { }

public class UserFeedbackSubmitFailureAction 
{
    public string ErrorMessage { get; }
    public UserFeedbackSubmitFailureAction(string errorMessage)
    {
        ErrorMessage = errorMessage;
    }
}

public class UserFeedbackSubmitAction 
{
    public UserFeedbackModel UserFeedbackModel { get; }

    public UserFeedbackSubmitAction(UserFeedbackModel userFeedbackModel)
    {
        UserFeedbackModel = userFeedbackModel;
    }
}
Enter fullscreen mode Exit fullscreen mode

We'll need one Effect for the form submit:

public class UserFeedbackEffects 
{
    private readonly HttpClient _httpClient;
    public UserFeedbackEffects(HttpClient httpClient)
    {
        _httpClient = httpClient;
    }

    [EffectMethod]
    public async Task SubmitUserFeedback(UserFeedbackSubmitAction action, IDispatcher dispatcher) 
    {
        var response = await _httpClient.PostAsJsonAsync("Feedback", action.UserFeedbackModel);

        if (response.IsSuccessStatusCode)
        {
            dispatcher.Dispatch(new UserFeedbackSubmitSuccessAction());
        }
        else 
        {
            dispatcher.Dispatch(new UserFeedbackSubmitFailureAction(response.ReasonPhrase));
        }            
    }
}

Enter fullscreen mode Exit fullscreen mode

The EffectMethod will accept the Model as part of the action, and use the injected HttpClient to post it to the ApiController we'll soon create. It will dispatch either a Success or Failure action when it's done.

And finally for the store, the ReducerMethods:

public static class UserFeedbackReducers
{
    [ReducerMethod(typeof(UserFeedbackSubmitAction))]
    public static UserFeedbackState OnSubmit(UserFeedbackState state) 
    {
        return state with 
        {
            Submitting = true
        };
    }

    [ReducerMethod(typeof(UserFeedbackSubmitSuccessAction))]
    public static UserFeedbackState OnSubmitSuccess(UserFeedbackState state)
    {
        return state with
        {
            Submitting = false,
            Submitted = true
        };
    }

    [ReducerMethod]
    public static UserFeedbackState OnSubmitFailure(UserFeedbackState state, UserFeedbackSubmitFailureAction action)
    {
        return state with
        {
            Submitting = false,
            ErrorMessage = action.ErrorMessage
        };
    }
}

Enter fullscreen mode Exit fullscreen mode

The ReducerMethods are straight-forward, just keeping track of the few properties that we'll use to decide what to display on the screen.

In the UserFeedback\Pages folder we'll add a Feedback.razor file as the page to hold the form. The EditForm will look like:

<EditForm Model="@model" OnValidSubmit="@HandleValidSubmit">
    <DataAnnotationsValidator />

    <div class="form-group">
        <label for="emailAddress">Email Address</label>
        <InputText class="form-control" id="emailAddress" @bind-Value="model.EmailAddress" />
    </div>

    <div class="form-group">
        <label for="rating">Rating (1-10)</label>
        <InputSelect class="form-control" id="rating" @bind-Value="model.Rating">
            <option>1</option>
            <option>2</option>
            <option>3</option>
            <option>4</option>
            <option>5</option>
            <option>6</option>
            <option>7</option>
            <option>8</option>
            <option>9</option>
            <option>10</option>
        </InputSelect>
    </div>

    <div class="form-group">
        <label for="comment">Comment</label>
        <InputTextArea class="form-control" id="comment" @bind-Value="model.Comment" rows="3"></InputTextArea>
    </div>

    <button class="btn btn-primary" type="submit">Submit</button>
    <ValidationSummary />
</EditForm>

Enter fullscreen mode Exit fullscreen mode

The form has all the AspNetCore goodness: two-way databinding, DataAnnotationsValidator, ValidationSummary, etc. The HandleValidSubmit method will only be invoked once all of the form fields pass all validation.

The entire razor page, including the @code block and all of the code deciding which portions of the screen to display is below:

@page "/feedback"
@inherits FluxorComponent

@using BlazorWithFluxor.Client.Features.UserFeedback.Store

@inject IState<UserFeedbackState> UserFeedbackState
@inject IDispatcher Dispatcher

<h3>User Feedback</h3>

@if (UserFeedbackState.Value.Submitting)
{
    <div>
        Submitting... Please wait.
    </div>
}
else if (UserFeedbackState.Value.Submitted && string.IsNullOrWhiteSpace(UserFeedbackState.Value.ErrorMessage))
{
    <div class="alert alert-success">
        Thank you for sharing!
    </div>
}
else
{
    <EditForm Model="@model" OnValidSubmit="@HandleValidSubmit">
        <DataAnnotationsValidator />

        <div class="form-group">
            <label for="emailAddress">Email Address</label>
            <InputText class="form-control" id="emailAddress" @bind-Value="model.EmailAddress" type="email" />
        </div>

        <div class="form-group">
            <label for="rating">Rating (1-10)</label>
            <InputSelect class="form-control" id="rating" @bind-Value="model.Rating">
                <option>1</option>
                <option>2</option>
                <option>3</option>
                <option>4</option>
                <option>5</option>
                <option>6</option>
                <option>7</option>
                <option>8</option>
                <option>9</option>
                <option>10</option>
            </InputSelect>
        </div>

        <div class="form-group">
            <label for="comment">Comment</label>
            <InputTextArea class="form-control" id="comment" @bind-Value="model.Comment" rows="3"></InputTextArea>
        </div>

        <button class="btn btn-primary" type="submit">Submit</button>
        <ValidationSummary />
    </EditForm>
}

@if (!string.IsNullOrWhiteSpace(UserFeedbackState.Value.ErrorMessage))
{
    <div class="alert alert-danger">
        Error: @UserFeedbackState.Value.ErrorMessage
    </div>
}

@code {
    private UserFeedbackModel model => UserFeedbackState.Value.Model;

    private void HandleValidSubmit()
    {
        Dispatcher.Dispatch(new UserFeedbackSubmitAction(UserFeedbackState.Value.Model));
    }
}
Enter fullscreen mode Exit fullscreen mode

To get to the page we add an item to the NavMenu:

<li class="nav-item px-3">
    <NavLink class="nav-link" href="feedback">
        <span class="oi oi-list-rich" aria-hidden="true"></span> Feedback
    </NavLink>
</li>
Enter fullscreen mode Exit fullscreen mode

And finally, we need a place to POST the form to. Let's create a FeedbackController in the Server project:

using BlazorWithFluxor.Shared;
using Microsoft.AspNetCore.Mvc;
using System;

namespace BlazorWithFluxor.Server.Controllers
{
    [ApiController]
    [Route("[controller]")]
    public class FeedbackController : ControllerBase
    {
        [HttpPost]
        public void Post(UserFeedbackModel model)
        {
            var email = model.EmailAddress;
            var rating = model.Rating;
            var comment = model.Comment;

            Console.WriteLine($"Received rating {rating} from {email} with comment '{comment}'");
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

The Post action receives the same UserFeedbackModel class that the EditForm was bound to. We can (and should) re-validate the model here before further processing, but for this example I'm just going to log the contents of the model to the console.

Alt Text

As you can see, all of the built-in features of the EditForm and DataAnnotationsValidator are available, plus the state of the form was maintained by Fluxor when I navigated away from and then back to the form. The best of both worlds.

Happy Coding!

Discussion (6)

Collapse
mrpmorris profile image
Peter Morris

My approach is to allow the user to edit the DTO that is sent to the API.

When the form is submitted, I Dispatch an action containing that DTO.
A Reducer sets IsSaving = true
An effect calls the server
If the server indicates success then I dispatch a different action with the DTO in it
Any state that has an interest in that piece of data (e.g. Client) has a reducer that reduces the values of the DTO properties into their own immutable states.

You should avoid having mutable state.

Collapse
mr_eking profile image
Eric King Author

Yes, I'm doing exactly the same thing, with the one exception that I'm also storing the form's DTO (which I'm calling "model" above) into the form component's state.

I feel that's ok, since there's no chance for the state of that form's DTO to have any effect on anything else in the application's state. Not other features' states, not even its own feature's state. It merely allows the form's field values a place to live.

If there were something other than the data-bound form itself that has interest in the DTO properties then they would have to get that value via a reducer like everything else.

Collapse
padnom profile image
padnom

Thx for you response.
Yes sessionStorage should be a good solution.
As you said depends of how many states need to be saved.
But in your opinion is it in the Effects classes that we should persiste the states in session before to disptach.

Collapse
mr_eking profile image
Eric King Author

Yes, pretty much. I have added another post to the series, with examples of that approach. Let me know what you think!

Collapse
padnom profile image
padnom

Thx for you post, very Useful.
I Have a question. How you handle if users refresh page.
Counterstate will be reset. If you want to keep counterState.
How would you implement this?

Collapse
mr_eking profile image
Eric King Author • Edited

Well, you would have to take the state and store it off somewhere so that it could be retrieved later.

One place to put the values of the store might be in the browser's localStorage, which would allow you to retrieve the state later for that user on that browser.

You could also store the state in a database somewhere, so it can be retrieved by the user regardless of which browser they are using, as long as they log in so you know who they are.

But how much of the state do you store? How often? And where? How secure do you want that persisted state to be? The answers to those questions really depend on the application.