Blazor Shopping Cart Sample using Local Storage to Persist State

This little demo is based on a Gist written by Steve Sanderson where he shows how to persist state in the browser using sessionStorage. His demo shows how to persist the counter value but I wanted to try something a bit more real-world, a very simple shopping cart, and also to use localStorage rather than sessionStorage. You can download the project here.

Here’s how it works. Select an item from the store (in this case a list). The details of that item are shown below the list. Enter a quantity and add it to your cart. The contents of your cart are shown below that, including the grand total. Now refresh the browser (or close and re-open). If you refresh within 30 seconds, your cart is still there. If you wait longer than 30 seconds before refreshing the browser, the cart data is no longer there.

My top level class is called Cart. Every time you update the cart, the contents are serialized and stored in the browser’s local storage using a not-as-yet-released package, Microsoft.AspNetCore.ProtectedBrowserStorage.

I have added a couple fields to the Cart class to support expiration:

    public DateTime LastAccessed { get; set; }
    public int TimeToLiveInSeconds { get; set; } = 30; // default

Whenever the Cart is persisted to localStorage, the LastAccessed time stamp is saved. When loading the cart from localStorage, a check is made to see if the cart has expired based on the LastAccessed and TimeToLiveInSeconds properties. I set it to 30 seconds by default so you could easily test it without having to wait around for a long period of time to elapse.

The Code

I created a Blazor Server project in Visual Studio 2019 called BlazorStateTest1.

Let’s start with the three models:

    public class Product
    {
        public int Id { get; set; }
        public string Name { get; set; }
        public string Description { get; set; }
        public decimal Price { get; set; }
    }
    
    public class CartProduct
    {
        public int Quantity { get; set; }
        public Product Product { get; set; }

        public decimal Total
        {
            get
            {
                return Product.Price * Quantity;
            }
        }
    }
    
    public class Cart 
    {
        public List<CartProduct> Items { get; set; } = new List<CartProduct>();

        public Decimal Total
        {
            get
            {
                decimal total = (decimal)0.0;
                foreach (var item in Items)
                {
                    total += item.Total;
                }
                return total;
            }
        }
        public DateTime LastAccessed { get; set; }
        public int TimeToLiveInSeconds { get; set; } = 30; // default
    }    

At the very bottom of the food chain is Product. Simple enough. Then we have a CartProduct which represents a Product and Quantity, and has a Total property that multiplies the product price by the quantity.

Finally, the Cart object has a list of CartProducts and the aforementioned properties to support expiration. This is a greatly simplified shopping cart. The focus here should be on the persistence patterns.

Following Steve’s instruction, I added the Microsoft.AspNetCore.ProtectedBrowserStorage package to the project.

Next, I added this line to my ConfigureServices method in Startup.cs:

    services.AddProtectedBrowserStorage();

Next, I created a component called CartStateProvider.razor to handle loading and saving the cart:

@inject ProtectedLocalStorage ProtectedLocalStore

@if (hasLoaded)
{
    <CascadingValue Value="@this">
        @ChildContent
    </CascadingValue>
}
else
{
    <p>Loading...</p>
}

@code {

    [Parameter] 
    public RenderFragment ChildContent { get; set; }

    public Cart ShoppingCart { get; set; }

    bool hasLoaded;

    protected override async Task OnParametersSetAsync()
    {
        // Retrieve the Shopping Cart from Local Storage (in the browser)
        ShoppingCart = await ProtectedLocalStore.GetAsync<Cart>("MyShoppingCart");

        // If the Cart is not there or empty...
        if (ShoppingCart == null || ShoppingCart.Items.Count == 0)
        {
            // Create a new Cart
            ShoppingCart = new Cart();
        }
        else
        {
            // otherwise, check to see if the Cart has expired (default is 30 seconds)
            if (DateTime.Now > ShoppingCart.LastAccessed.AddSeconds(ShoppingCart.TimeToLiveInSeconds))          
            {
                // Expired. Create a new cart.
                ShoppingCart = new Cart();
            }
        }
        ShoppingCart.LastAccessed = DateTime.Now;
        hasLoaded = true;
    }

    public async Task SaveChangesAsync() 
    {
        // Set the time stamp to current time and save to local storage (in the browser).
        ShoppingCart.LastAccessed = DateTime.Now;
        await ProtectedLocalStore.SetAsync("MyShoppingCart", ShoppingCart);
    }
}

This component has an instance of Cart (ShoppingCart) which it loads and saves to localStorage.

Rather than just instantiating a CartStateProvider in index, I took Steve’s advice and made it a Cascading Parameter so it could be used everywhere. To do that, I wrapped the Router in App.razor in an instance of CartStateProvider:

<BlazorStateTest1.Shared.CartStateProvider>
    <Router AppAssembly="@typeof(Program).Assembly">
        <Found Context="routeData">
            <RouteView RouteData="@routeData" DefaultLayout="@typeof(MainLayout)" />
        </Found>
        <NotFound>
            <LayoutView Layout="@typeof(MainLayout)">
                <p>Sorry, there's nothing at this address.</p>
            </LayoutView>
        </NotFound>
    </Router>
</BlazorStateTest1.Shared.CartStateProvider>

Finally, we can use it in the Index page (the default page for Blazor projects):

@page "/"

@if (AllProducts != null)
{
    // This demo is based on a technique Steve Sanderson Showed in the following Github Gist:
    // https://gist.github.com/SteveSandersonMS/ba16f6bb6934842d78c89ab5314f4b56

    <h2>Select an item</h2>
    //Display the list of products. Call ProductSelected when one is selected
    <select size="4" style="width:100%;" @onchange="ProductSelected">
        @foreach (var product in AllProducts)
        {
            <option value="@product.Id.ToString()">@product.Name</option>
        }
    </select>
    <br />

    // Show the selected product
    @if (SelectedProduct != null && ShowItem == true)
    {
        <div style="padding:1vw;background-color:lightgray;">
            <table cellpadding="5" cellspacing="5">
                <tr>
                    <td align="right" valign="top"><strong>Name:</strong></td>
                    <td align="left" valign="top">@SelectedProduct.Name</td>
                </tr>
                <tr>
                    <td align="right" valign="top"><strong>Description:</strong></td>
                    <td align="left" valign="top">@SelectedProduct.Description</td>
                </tr>
                <tr>
                    <td align="right" valign="top"><strong>Price:</strong></td>
                    <td align="left" valign="top">$@SelectedProduct.Price</td>
                </tr>
                <tr>
                    <td align="right" valign="top"><strong>Add To Cart:</strong></td>
                    <td align="left" valign="top">
                        Quantity:
                        <input @bind="Quantity" />
                        <button @onclick="AddToCart">Add</button>
                    </td>
                </tr>
            </table>
        </div>
    }

    // Show the cart contents if there are items in it.
    @if (CartStateProvider != null && CartStateProvider.ShoppingCart.Items.Count > 0)
    {
        <br />
        <h3>Your Cart:</h3>
        <h4>Total: $@CartStateProvider.ShoppingCart.Total</h4>
        <table cellpadding="5" cellspacing="5">
            @foreach (var item in CartStateProvider.ShoppingCart.Items)
            {
                <tr>
                    <td colspan="2">
                        <hr />
                    </td>
                </tr>
                <tr>
                    <td align="right" valign="top"><strong>Name:</strong></td>
                    <td align="left" valign="top">@item.Product.Name</td>
                </tr>
                <tr>
                    <td align="right" valign="top"><strong>Description:</strong></td>
                    <td align="left" valign="top">@item.Product.Description</td>
                </tr>
                <tr>
                    <td align="right" valign="top"><strong>Price:</strong></td>
                    <td align="left" valign="top">$@item.Product.Price</td>
                </tr>
                <tr>
                    <td align="right" valign="top"><strong>Quantity:</strong></td>
                    <td align="left" valign="top">@item.Quantity</td>
                </tr>
                <tr>
                    <td align="right" valign="top"><strong>Total:</strong></td>
                    <td align="left" valign="top">$@item.Total</td>
                </tr>
                <tr>
                    <td colspan="2">
                        @*Clicking this button passes the item so we can remove it*@
                        <button @onclick="@(() => RemoveItem(@item))">Remove</button>
                    </td>
                </tr>
            }
        </table>
        <br />
        <h4>Total: $@CartStateProvider.ShoppingCart.Total</h4>
    }

}

@code {

    // Cascading Parameters and Values flow down the entire component tree
    [CascadingParameter] CartStateProvider CartStateProvider { get; set; }

    bool ShowItem = false;
    string Quantity = "1";
    List<Product> AllProducts;
    Product SelectedProduct;

    void ProductSelected(ChangeEventArgs args)
    {
        // User clicked on an item in the list.
        // Show the product and give them an option to add to cart.
        SelectedProduct = (from x in AllProducts
                           where x.Id == Convert.ToInt32(args.Value)
                           select x).First();
        Quantity = "1";
        ShowItem = true;
    }

    async Task AddToCart()
    {
        // Create a new item for the shopping cart
        var item = new CartProduct
        {
            Product = SelectedProduct,
            Quantity = Convert.ToInt32(Quantity)
        };
        // Add it to the cart
        CartStateProvider.ShoppingCart.Items.Add(item);
        // Save to local storage
        await CartStateProvider.SaveChangesAsync();
        // Stop displaying the selected item
        ShowItem = false;
    }

    async Task RemoveItem(CartProduct Item)
    {
        // User clicked a Remove button to remove an item from the cart.
        CartStateProvider.ShoppingCart.Items.Remove(Item);
        // Update the cart - save to localstorage
        await CartStateProvider.SaveChangesAsync();
    }

    protected override void OnInitialized()
    {
        // Create the products we can purchase
        AllProducts = new List<Product>();

        AllProducts.Add(new Product
        {
            Id = 1,
            Name = "1 lb. Bag of Yirgacheffe Coffee Beans",
            Description = "Yirgacheffe is a rich Ethiopian coffee that will poke your eye out",
            Price = (decimal)10.99
        });

        AllProducts.Add(new Product
        {
            Id = 2,
            Name = "Tablet Show Coffee Mug",
            Description = "Back by popular demand for a limited time, the long-coveted Tablet Show Coffee Mug",
            Price = (decimal)4.99
        });
    }
}

Everything starts at the OnInitialized method. That’s where I create two products and add them to the AllProducts list.

Now look at the top of the page. If the AllProducts list is loaded, we show the products in a select element.

When an item is selected, the ProductSelected method fires, and the SelectedProduct is set.

Next, we have logic that shows the product info if SelectedProduct isn’t null and the boolean ShowItem is set to true. That table includes an input element for the quantity and an Add button that calls the AddToCart method when clicked.

The AddToCart method then creates a CartProduct, adds it to the CartStateProvider.ShoppingCart, and persists it.

Next, if the CartStateProvider.ShoppingCart isn’t null and actually contains items, it is shown below, again in a table element. Each item has a Remove button that when clicked calls RemoveItem, which removes the item from the cart and again saves the cart.

Take a look at the expiry code in the CartStateProvider’s OnParametersSetAsync method. This happens when the page loads and the cart gets instantiated. This is the critical check right here:

if (DateTime.Now > ShoppingCart.LastAccessed.AddSeconds(ShoppingCart.TimeToLiveInSeconds)) 

If this statement is true, then the cart has expired and a new empty cart is returned.

Summary

LocalStorage is good for small amounts of data - Steve says storing a few K of data there is probably okay - but you should be thinking about server-side storage of state if the amount of data going over the wire is hindering performance. For small bits, however, localStorage is the perfect solution.



Carl Franklin has been a key leader in the Microsoft developer community since the very early days when he wrote for Visual Basic Programmers Journal. He authored the Q&A column of that magazine as well as many feature articles for VBPJ and other magazines. He has authored two books for John Wiley & Sons on sockets programming in VB, and in 1994 he helped create the very first web site for VB developers, Carl & Gary's VB Home Page.

Carl is a Microsoft MVP for Developer Technologies, and co-host of .NET Rocks!, one of the longest running podcasts ever (2002). Carl is also an accomplished musician and audio/video producer. He started Pwop Studios in 1999 as a record label for his first album, a collaboration with his brother Jay: Strange Communication. Franklin Brothers released Lifeboat To Nowhere in 2011, which has met with rave reviews. In 2013, Carl released his first solo album, Been a While, which features a tune with John Scofield on guitar, as well as an incredible group of musicians local to New London, CT.

Pwop Studios is a full-service audio and video post production studio in New London, CT, where Carl records and produces the podcasts as well as music and video projects - both for himself, Franklin Brothers Band, and the public.