DEV Community

Chris Sainty
Chris Sainty

Posted on • Originally published at chrissainty.com on

Auto Saving Form Data in Blazor

Auto Saving Form Data in Blazor

I got tagged in a thread on Twitter last week by Mr Rockford Lhotka. I've embedded the thread below so you can read it for yourself, but essentially he was voicing his frustration at losing data when an error/timeout/server error/etc occur in web applications.

I hate the web. I've hated the web forever. Type in a thoughtful bit of content, click save, get some server/gateway timeout, lose your content. You'd think we've have this solved, but no. 1995-present this is a problem. f-cking web.

β€” Rockford Lhotka (@RockyLhotka) April 29, 2020

Now, this type of situation very much depends on the application and how it's been developed, but, none the less, I agree. There is nothing worse than spending ages filling in a form and then for something to blow up and lose everything you've entered.

He and Dan Wahlin were discussing the idea of a form auto saving data so it could be recovered if something went wrong with the application. I got tagged by Rockford suggesting it would be a great addition to Blazored – Challenge accepted! πŸ˜ƒ

Speaking of which - a good thing for @chris_sainty to add to his blazord project? 😁😁😁

β€” Rockford Lhotka (@RockyLhotka) April 29, 2020

In this post, I'm going to show you a solution I've come up with for auto saving and rehydrating form data in Blazor. I'm going to start by outlining the goals and boundaries I set for this project. Then I'm going to tell you about some options I decided not to go with and why I didn't pursue them. Then I'll get into the meat of things and step through the solution I developed. I'll show how I satisfied each goal set out at the start. Then I'll finish by showing you how using the solution compares to the existing EditForm experience.

I want to be clear this is by no means a bullet proof, cover all bases solution. It's more an MVP to be built on. I'm hoping to release this as a new package under Blazored after a bit more development and refinement. If you want to help out with that then I've included a link to the new repo at the end of the post.

Defining the Goals

To give myself some focus and boundaries I made a list of four things that needed to be achieved in order for this to be considered useful.

  1. Save data to local storage when it's entered into each form controls
  2. Rehydrate data from local storage when the user returns to the page
  3. Clear saved data from local storage when it's successfully submitted by the form
  4. Try to keep things as close to the current experience with EditForm as possible for developers

There were also some things that I wanted to rule out for now.

  • Only persist to local storage
  • Don't worry about complex forms with nested complex objects

Now I had a defined scope for the work, I could start playing around with some ideas and see what I could come up with.

The Cutting Room Floor

As you would expect, I went though many failed ideas before I settled on a solution I was happy with. I thought it might be interesting to briefly highlight what they were and why I didn't pursue them.

First attempt

I started with the idea of creating a new component which could be nested inside an EditForm, similar to the DataAnnotationValidator. I quickly got something in-place which used the cascaded EditContext, provided by EditForm, to hook into the OnFieldChanged event. From here I could intercept model updates and persist the model to local storage.

The problem came when trying to rehydrate the form. Due to where my component sat in the component tree, the UI wasn't updating to show the values in the textboxes. I needed a StateHasChanged call higher in the tree and I just couldn't get that to work in a nice way.

I also would need to know when the form had been submitted so I could achieve number 3 on my goals list, clearing the saved values on a successful form post. There was no way I could reliably do that using the EditContext alone.

Second attempt

After some hacking about I came to the conclusion that the best option was going to be a new form component, replacing EditForm. I wanted to save duplicating all of the code from EditForm and just inherit from it. I could then add some additional functionality to save and load the form values.

Unfortunately, that was a short lived hope. I needed access to some members which were private. I got round that with some reflection foo, but the show stopper was needing to add some additional logic in the HandleSubmitAsync method, there was no way to hack this.

Conclusion

In the end, I concluded that I would need to duplicate the EditForm component and use it as a starting point for my new form component. It wasn't the end of the world, after my experimenting I was happy with the fact I had to change the behaviour of the original component enough that just extending it wasn't going to work.

With this decision made I then moved on to what became the solution I ended up with.

Building the AutoSaveEditForm Component

The starting point for the new component was the existing EditForm component produced by the Blazor team. If you want to see this in its unaltered state you can find it here. Let's go through each of the changes I made and why.

Integrating with Local Storage

The first thing I did was to add the functionality to persist and retrieve the form to and from local storage. This required injecting IJSRuntime into the component and then adding the following two methods.

private async void SaveToLocalStorage(object sender, FieldChangedEventArgs args)
{
    var model = Model ?? _fixedEditContext.Model;
    var serializedData = JsonSerializer.Serialize(model);
    await _jsRuntime.InvokeVoidAsync("localStorage.setItem", Id, serializedData);
}

private async Task<object> LoadFromLocalStorage()
{
    var serialisedData = await _jsRuntime.InvokeAsync<string>("localStorage.getItem", Id);
    if (serialisedData == null) return null;
    var modelType = EditContext?.Model.GetType() ?? Model.GetType();

    return JsonSerializer.Deserialize(serialisedData, modelType);
}

Both of these methods do exactly what they say on the tin. SaveToLocalStorage persists the current form model and LoadFromLocalStorage retrieves the form model.

In order to identify a particular form, I added an Id parameter to the component which you can see used in the code above. It's worth pointing out that this is definitely something which will need to be improved before the component could be used in a real app – this method won't be enough to guarantee uniqueness across an application.

Hooking into Field Changes

With the local storage interfaces in place I moved on to saving the form when fields were updated. This required making some changes to the existing OnParametersSet method. There's an if statement at the end of the original method which looks like this.

if (_fixedEditContext == null || EditContext != null || Model != _fixedEditContext.Model)
{
    _fixedEditContext = EditContext ?? new EditContext(Model);
}

I updated it to this.

if (_fixedEditContext == null || EditContext != null || Model != _fixedEditContext.Model)
{
    _fixedEditContext = EditContext ?? new EditContext(Model);
    _fixedEditContext.OnFieldChanged += SaveToLocalStorage;
}

I added an extra line which registers a handler for the EditContexts OnFieldChanged event. Now, whenever a field on the form model is updated the SaveToLocalStorage method will be called and the form will be persisted to local storage.

With the above changes I'd satisfied number 1 on my goals list:

  1. Save data to local storage when it's entered into each form controls
  2. Rehydrate data from local storage when the user returns to the page
  3. Clear saved data from local storage when it's successfully submitted by the form
  4. Try to keep things as close to the current experience with EditForm as possible for developers

Rehydrating the Form Model

I played around with a lot of ideas for this and ended up settling on the use of reflection. This was because I needed to update the specific instance of the model passed in, anything else I tried ultimately ended up overwriting that instance with a new one.

I came up with the following method which is used to copy the values from the form model retrieved from local storage, to the active form model.

public void Copy(object savedFormModel, object currentFormModel)
{
    var savedFormModelProperties = savedFormModel.GetType().GetProperties();
    var currentFormModelProperties = currentFormModel.GetType().GetProperties();

    foreach (var savedFormModelProperty in savedFormModelProperties)
    {
        foreach (var currentFormModelProperty in currentFormModelProperties)
        {
            if (savedFormModelProperty.Name == currentFormModelProperty.Name && savedFormModelProperty.PropertyType == currentFormModelProperty.PropertyType)
            {
                var childValue = currentFormModelProperty.GetValue(currentFormModel);
                var parentValue = savedFormModelProperty.GetValue(savedFormModel);

                if (childValue == null && parentValue == null) continue;

                currentFormModelProperty.SetValue(currentFormModel, parentValue);

                var fieldIdentifier = new FieldIdentifier(currentFormModel, currentFormModelProperty.Name);
                _fixedEditContext.NotifyFieldChanged(fieldIdentifier);

                break;
            }
        }
    }
}

In a nutshell, this code is looping over each property on the saved form model and then finding that same property on the current form model and transferring the value.

A key thing to note is the call to _fixedEditContext.NotifyFieldChanged(fieldIdentifier);. This lets the current edit context know that the value has been updated and triggers all the right events, a key one being any validation for that field.

With that method in placed I overrode the OnAfterRender lifecycle method and added the following code.

protected override async Task OnAfterRenderAsync(bool firstRender)
{
    if (firstRender)
    {
        var savedModel = await LoadFromLocalStorage();

        if (Model is object && savedModel is object)
        {
            Copy(savedModel, Model);
            StateHasChanged();
        }
        else if (savedModel is object)
        {
            Copy(savedModel, _fixedEditContext.Model);
            StateHasChanged();
        }
    }
}

When the component first renders, this code will check local storage for an existing saved form model. If one is found then it will use the Copy method we just looked at to copy values from the saved form model to the current one. Once this has been done it will call StateHasChanged, this is important as it will refresh the UI and show the newly copied values in the form controls.

This was number 2 ticked off the list of goals, form values were now being reloaded from local storage when a user revisited a page.

  1. Save data to local storage when it's entered into each form controls
  2. Rehydrate data from local storage when the user returns to the page
  3. Clear saved data from local storage when it's successfully submitted by the form
  4. Try to keep things as close to the current experience with EditForm as possible for developers

Clearing saved form data on a successful submit

This one took me a bit of time to get working and for a very specific reason. If I wanted to honour number 4 on my goals list, keep things close to the current developer experience, I wanted to allow developers to still use the same form events OnSubmit, OnValidSubmit and OnInvalidSubmit.

The original method which handles the submit events is called HandleSubmitAsync and looks like this.

private async Task HandleSubmitAsync()
{
    if (OnSubmit.HasDelegate)
    {
        await OnSubmit.InvokeAsync(_fixedEditContext);
    }
    else
    {
        if (isValid && OnValidSubmit.HasDelegate)
        {
            await OnValidSubmit.InvokeAsync(_fixedEditContext);
        }

        if (!isValid && OnInvalidSubmit.HasDelegate)
        {
            await OnInvalidSubmit.InvokeAsync(_fixedEditContext);
        }
    }
}

Keeping OnInvalidSubmit doesn't require any action in terms of the saved form model. For OnValidSubmit I only had to make the following change to clear local storage after the OnValidSubmit event has been invoked.

if (isValid && OnValidSubmit.HasDelegate)
{
    await OnValidSubmit.InvokeAsync(_fixedEditContext);

    // Clear saved form model from local storage
    await _jsRuntime.InvokeVoidAsync("localStorage.removeItem", Id);
}

The headache came when trying to keep the OnSubmit event. This event requires the developer to handle the validation of the form manually and then submit it. With the existing design, there is no way for me to know if the form had been submitted and that the saved form model could be removed.

After some back and fourth I came up with the following design which does require some extra steps from the consumer but I think is worth the trade off.

The original OnSubmit event is typed as an EventCallback<EditContext> as follows.

[Parameter] public EventCallback<EditContext> OnSubmit { get; set; }

I updated the definition of the OnSubmit parameter to this.

[Parameter] public Func<EditContext, Task<bool>> OnSubmit { get; set; }

I'm now requiring the developer to register a method which returns a bool to let me know if the form was submitted successfully or not. Making this change did break a check performed in OnParametersSet.

if (OnSubmit.HasDelegate && (OnValidSubmit.HasDelegate || OnInvalidSubmit.HasDelegate))

This just required a simple update.

if (OnSubmit is object && (OnValidSubmit.HasDelegate || OnInvalidSubmit.HasDelegate))

Once that was fixed I could then update the code in HandleSubmitAsync to this.

if (OnSubmit is object)
{
    var submitSuccess = await OnSubmit.Invoke(_fixedEditContext);
    if (submitSuccess)
    {
        await _jsRuntime.InvokeVoidAsync("localStorage.removeItem", Id);
    }
}

I could now await the bool value from the consumer code and if it's true the saved form model is removed form local storage. The complete updated HandleSubmitAsync method looks like this.

private async Task HandleSubmitAsync()
{
    if (OnSubmit is object)
    {
        var submitSuccess = await OnSubmit.Invoke();
        if (submitSuccess)
        {
            await _jsRuntime.InvokeVoidAsync("localStorage.removeItem", Id);
        }
    }
    else
    {
        if (isValid && OnValidSubmit.HasDelegate)
        {
            await OnValidSubmit.InvokeAsync(_fixedEditContext);
            await _jsRuntime.InvokeVoidAsync("localStorage.removeItem", Id);
        }

        if (!isValid && OnInvalidSubmit.HasDelegate)
        {
            await OnInvalidSubmit.InvokeAsync(_fixedEditContext);
        }
    }
}

At this point I was happy to tick off number 3 on my goals list.

  1. Save data to local storage when it's entered into each form controls
  2. Rehydrate data from local storage when the user returns to the page
  3. Clear saved data from local storage when it's successfully submitted by the form
  4. Try to keep things as close to the current experience with EditForm as possible for developers

The only remaining goal was number 4, and the only way to tick that off is to check a few things from the consuming developers point of view and see how it compares to the original EditForm component.

Using the AutoSaveEditForm Component

Let's look at a simple example of form usage first. We'll use a model which contains two fields, FirstName and LastName. To make sure validation is working we'll also make the LastName required.

public class MyFormModel
{
    public string FirstName { get; set; }

    [Required]
    public string LastName { get; set; }
}

First we'll look at using EditForm and its OnValidSubmit event. The code is as follows.

<EditForm Model="myFormModel" OnValidSubmit="HandleValidSubmit">
    <DataAnnotationsValidator />

    <InputText @bind-Value="myFormModel.FirstName" />
    <InputText @bind-Value="myFormModel.LastName" />

    <button type="submit">Submit</button>
</EditForm>

@code {
    private MyFormModel myFormModel = new MyFormModel();

    private void HandleValidSubmit()
    {
        Console.WriteLine($"Form Submitted For: {myFormModel.FirstName} {myFormModel.LastName}");
        myFormModel = new MyFormModel();
    }
}

So how does this compare with AutoSaveEditForm?

<AutoSaveEditForm Id="form-one" Model="myFormModel" OnValidSubmit="HandleValidSubmit">
    <DataAnnotationsValidator />

    <InputText @bind-Value="myFormModel.FirstName" />
    <InputText @bind-Value="myFormModel.LastName" />

    <button type="submit">Submit</button>
</AutoSaveEditForm>

@code {
    private MyFormModel myFormModel = new MyFormModel();

    private void HandleValidSubmit()
    {
        Console.WriteLine($"Form Submitted For: {myFormModel.FirstName} {myFormModel.LastName}");
        myFormModel = new MyFormModel();
    }
}

The only changes needed were the name of the component and the addition of the Id parameter. That's pretty cool, really minimal changes for existing developers. But what about a slightly more complex example?

Let's look at the scenario where the developer is using the OnSubmit event and manually validating. This is what the code looks like with the EditForm component.

<EditForm Model="myFormModel" OnSubmit="HandleSubmit">
    <DataAnnotationsValidator />

    <InputText @bind-Value="myFormModel.FirstName" />
    <InputText @bind-Value="myFormModel.LastName" />

    <button type="submit">Submit</button>
</EditForm>

@code {
    private MyFormModel myFormModel = new MyFormModel();

    private void HandleSubmit(EditContext editContext)
    {
        var isValid = editContext.Validate();

        if (isValid)
        {
            Console.WriteLine($"Form Submitted For: {myFormModel.FirstName} {myFormModel.LastName}");
            myFormModel = new MyFormModel();
        }
        else
        {
            Console.WriteLine($"Form Invalid");
        }
    }
}

Here's the code with auto save.

<AutoSaveEditForm Id="form-one" Model="myFormModel" OnSubmit="HandleSubmit">
    <DataAnnotationsValidator />

    <InputText @bind-Value="myFormModel.FirstName" />
    <InputText @bind-Value="myFormModel.LastName" />

    <button type="submit">Submit</button>
</AutoSaveEditForm>

@code {
    private MyFormModel myFormModel = new MyFormModel();

    private async Task<bool> HandleSubmit(EditContext editContext)
    {
        var isValid = editContext.Validate();

        if (isValid)
        {
            Console.WriteLine($"Form Submitted For: {myFormModel.FirstName} {myFormModel.LastName}");
            myFormModel = new MyFormModel();

            return true;
        }
        else
        {
            Console.WriteLine($"Form Invalid");
            StateHasChanged();
            return false;
        }
    }
}

I think you'll agree, that's not bad and sticks pretty close to the original developer experience.

While I appreciate these are simple examples, I'm happy to take that as a win and tick off number 4 on the goals list.

  1. Save data to local storage when it's entered into each form controls
  2. Rehydrate data from local storage when the user returns to the page
  3. Clear saved data from local storage when it's successfully submitted by the form
  4. Try to keep things as close to the current experience with EditForm as possible for developers

I've created a gif to show what this looks like from the users perspective when using the component in an application.

Auto Saving Form Data in Blazor

I just want to reiterate again, this is not a fool-proof and complete solution. It's just a starting point. I do think it's really awesome that all this has been possible using just C# code. As you may have noticed, I've not had to write any JavaScript to achieve this.

Blazored AutoSaveEditForm

As I mentioned at the start, this is the starting point for a new component I'm adding to Blazored – you can find the repo here. It contains the complete code from this post and any suggestions are welcome, just open an issue. Also if anyone wants to help out with getting the component to a state where it's ready for public use, please let me know.

Summary

If you've made it this far, well done! For those looking for the complete source code I haven't published this yet as what you've just read about is the starting point for a new component which is going to be added to Blazored very soon.

In this post I've walked you though my design for a new form component which will persist form values to local storage until the form is submitted successfully. I started by showing why I was attempting this and the goals and boundaries for this work.

Then I talked about some of the brain storming I did and some other options I decided not to go with for the end design. Before taking you through the design for the end component which is based on the original EditForm component produced by the Blazor team.

Top comments (0)