Read More on Telerik Blogs
October 06, 2025 Blazor, Web
Get A Free Trial

We will learn how to leverage the Local Storage API to store temporary application state in Blazor WebAssembly.

In web applications, we have to play within the rules and boundaries that web browsers provide to our applications.

For example, in contrast to desktop applications, where we have complete control over the state of our application, web application users can navigate back or even close the active tab or the entire browser at any time. This circumstance forces web developers to put more effort into handling application state.

In this article, we will learn how to use the local storage in Blazor WebAssembly to save and restore form input in data-driven web applications.

The Concept

Instead of losing all application state when the user refreshes the page, we want to save the values they entered into the form fields using local storage, a standard web browser API. We’ll use a simple contact form, but this concept can be extended and used for managing complex application state, such as complete workflows.

Whenever a user leaves a field, we want to store the value in local storage. With this strategy, we can later reload the data from the local storage when the user returns after an (un)intended break from working within the application.

Bonus: Instead of forcefully restoring the application state, we can go further and kindly let the user choose whether to reinstantiate the previous state, start from scratch with the workflow or fill in the form fields. However, that’s beyond the scope of this example.

The Contact Form Example

I will highlight the most critical parts of the code in this article, but leave out bits and pieces to avoid bloating the article too much. You can access the code used in this example on GitHub.

First, let’s create a Contact page component containing a form with typical fields for such a scenario.

We have name, email, subject, message fields and a Send Message button.

The template code within the Contact.razor page component looks like this:

@page "/contact"
@using System.ComponentModel.DataAnnotations
<PageTitle>Contact</PageTitle>
<h1>Contact</h1>

<EditForm Model="@Model" OnValidSubmit="@HandleValidSubmit" FormName="Contact">
    <DataAnnotationsValidator />
    <ValidationSummary />

    <InputText id="name"
		Value="@Model.Name"
        ValueChanged="@(async value => await OnFieldChanged(nameof(Model.Name), value))"
        ValueExpression="@(() => Model.Name)" />
    <InputText id="email" type="email" class="form-control" 
        Value="@Model.Email"
        ValueChanged="@(async value => await OnFieldChanged(nameof(Model.Email), value))"
        ValueExpression="@(() => Model.Email)" />
    <InputText id="subject" class="form-control" 
        Value="@Model.Subject"
        ValueChanged="@(async value => await OnFieldChanged(nameof(Model.Subject), value))"
        ValueExpression="@(() => Model.Subject)" />
	<InputTextArea id="message" class="form-control" Rows="5" 
        Value="@Model.Message"
        ValueChanged="@(async value => await OnFieldChanged(nameof(Model.Message), value))"
        ValueExpression="@(() => Model.Message)" />

    <button type="submit">Send Message</button>
</EditForm>

@if (isSubmitted)
{
    <div>
        Thank you! Your message has been sent.
    </div>
}

Again, I shortened the example code shown here and removed the CSS styling, mainly Bootstrap classes, to make it more expressive and concise.

Hint: We use the EditForm component, which is helpful with form handling. See the Advanced Blazor Form Validation article for more details about how the EditForm component works.

We use the DataAnnotationsValidator and ValidationSummary components inside the EditForm component and the InputText and InputTextArea components to render the input fields.

The most noticeable difference between a regular form and this example is that we don’t use the @bind-Value directive. Instead, we bind the Value, ValueChanged and ValueExpression properties individually.

It gives us more control over the behavior, especially executing code when the value changes. In this example, we call an OnFieldChanged method and provide the name of the field and its value.

Last, we have a conditional rendering section showing a thank-you message when the form has been submitted.

Now let’s look at the code section of the component.

@code {
	private ContactModel Model = new ContactModel();
	private bool isSubmitted = false;
	
	private void HandleValidSubmit()
	{
		isSubmitted = true;
	}
	
	public class ContactModel
	{
		[Required(ErrorMessage = "Name is required.")]
		public string Name { get; set; } = string.Empty;

		[Required(ErrorMessage = "Email is required.")]
		[EmailAddress(ErrorMessage = "Invalid email address.")]
		public string Email { get; set; } = string.Empty;

		[Required(ErrorMessage = "Subject is required.")]
		public string Subject { get; set; } = string.Empty;

		[Required(ErrorMessage = "Message is required.")]
		[StringLength(1000, ErrorMessage = "Message is too long.")]
		public string Message { get; set; } = string.Empty;
	}
}

Here, we have the Model property that we reference in the template code, which contains the Name, Email, Subject and Message fields. We use data annotations to implement basic validation rules.

The HandleValidSubmit method usually stores the data in the database or calls a remote service. In this isolated example, we set the isSubmitted field to true.

This property change will trigger a rerender and show the Thank You message on the screen after a user presses the Send Message button.

Storing the User Input in Local Storage

Now that we have the form ready, we want to add the functionality to store the user input in local storage.

First, we add a using to the System.Text.Json namespace and inject an instance of the IJSRuntime object.

@using System.Text.Json
@inject IJSRuntime JS

Next, we add a static field containing a key for identifying the information stored in the local storage:

private const string StorageKey = "ContactForm";

We need to use JavaScript interoperability to access native browser APIs from Blazor.

Hint: If you have never used JavaScript interoperability in Blazor before, you might want to learn the fundamentals in the Blazor JavaScript Interop—Calling JavaScript from .NET article.

Next, we implement a StoreItem and a LoadItem method, which access the local storage and store or load data.

public ValueTask StoreItem(string key, object data)
{
    return JS.InvokeVoidAsync("localStorage.setItem", new object[]
    {
        key,
        JsonSerializer.Serialize(data)
    });
}

In the StoreItem method, we have two parameters. The first parameter accepts a key, and the second is a .NET object.

In the implementation, we use the injected object of type IJSRuntime and its InvokeVoidAsync method to call the localStorage.setItem function in JavaScript. We provide the key and a serialized version of the data object passed to the method to the setItem function.

public async Task<T?> LoadItem<T>(string key)
{
    var data = await JS.InvokeAsync<string>("localStorage.getItem", key);
    if (!string.IsNullOrEmpty(data))
    {
        return JsonSerializer.Deserialize<T>(data);
    }

    return default;
}

The LoadItem method is generic and accepts the key as its single method parameter. We use JavaScript interop to call the localStorage.getItem function and provide the key. We then return the deserialized object or null.

With those methods available, we can finally implement the OnFieldChanged method referenced in the ValueChanged events of the form input fields.

private async Task OnFieldChanged(string fieldName, object? newValue)
{
    if (fieldName == nameof(Model.Name))
    {
        Model.Name = newValue?.ToString() ?? string.Empty;
    }   
    if (fieldName == nameof(Model.Email))
    {
        Model.Email = newValue?.ToString() ?? string.Empty;
    }
    if (fieldName == nameof(Model.Subject))
    {
        Model.Subject = newValue?.ToString() ?? string.Empty;
    }
    if (fieldName == nameof(Model.Message))
    {
        Model.Message = newValue?.ToString() ?? string.Empty;
    }

    await StoreItem(StorageKey, Model);
}

Here, we can implement custom logic based on which field the OnFieldChanged method triggered. In this example, I keep it simple and assign the new value to the property on the Model object for each input field.

Independent of what field changed, we call the StoreItem method at the end of the method implementation to store the current information in the local storage. We provide the static StorageKey previously defined and the Model object.

Now, let’s build and run the application.

When we enter data in the form, we should be able to see the stored information in the local storage. We can verify that by opening the developer tools in the browser and navigating to the application tab. In the application tab, we select Local storage in the Storage group.

In the ContactForm key, we have a serialized object with the values for all four input fields in this form. However, when we navigate to another page in the application and go back to the form, it’s empty again.

Let’s load the data from the local storage when navigating to the Contact page next. We want to override the OnInitializedAsync lifecycle method to execute code whenever the component is initialized.

protected async override Task OnInitializedAsync()
{
    Model = await LoadItem<ContactModel>(StorageKey) ?? new ContactModel();
}

The implementation is simple, uses the LoadItem method we previously implemented, and provides the StorageKey as its single argument.

Now, let’s build and run the application.

You can now enter values in the form, navigate from one page to another and come back later and continue filling in the form without losing any information.

The Advantages of Using Local Storage for Temporary Application State

One of the benefits of using local storage for this task is the scalability.

This technique scales with the number of users because the information is stored on the client instead of the server. It relieves the server and saves CPU power and storage costs in large-scale environments.

Another benefit might be data security or data protection. While we often have measures to encrypt data at rest on disk or during transport, we don’t want these exact mechanisms applied to temporary storage, such as temporary application state, because that creates expensive overhead.

By leaving the temporary data on the client, we do not have this problem in most situations.

What About Blazor Server?

In this article, we focused on Blazor WebAssembly because we can directly call JavaScript interop from Blazor WebAssembly without any client/server loops.

With Blazor Server, we can also use the Local Storage APIs, but we need a round-trip to access the client’s local storage.

Hint: Another article titled Accessing Browser Storage in Blazor Web Applications in the Blazor Basics series uses the ProtectedBrowserStorage class, a good way to implement Local Storage access in Blazor Server applications.

However, in most Blazor Server scenarios where the code executes, I prefer using other techniques to temporarily store the application state on the server instead of the client.

Conclusion

In this article, we learned the fundamentals of how to store temporary application state, such as form input, in the local storage, a standard browser API.

Parts of the implementation, such as the StoreItem and LoadItem methods, can be extracted into a service and reused for multiple components.

We could extend this example and use the same technique to implement advanced scenarios, such as keeping track of client-side workflows without losing information when the user leaves the application or the power cuts out and the user continues later.

If you want to learn more about Blazor development, watch my free Blazor Crash Course on YouTube. And stay tuned to the Telerik blog for more Blazor Basics.


About the Author

Claudio Bernasconi

Claudio Bernasconi is a passionate software engineer and content creator writing articles and running a .NET developer YouTube channel. He has more than 10 years of experience as a .NET developer and loves sharing his knowledge about Blazor and other .NET topics with the community.

Related Posts