In this chapter, we will start looking at managing state. There is also a continuation of this chapter in Chapter 11, Managing State – Part 2.
There are many different ways of managing state or persisting data. Since this book focuses on Blazor, we will not explore how to connect to databases but create a simple JSON storage instead.
In the repo on GitHub, you can find more examples of storing data in databases such as RavenDB or MSSQL.
We will use a common pattern called the repository pattern.
We will also create an API to access the data from the JSON repository.
By the end of this chapter, we will have learned how to create a JSON repository and an API.
We will cover the following main topics:
Make sure you have followed the previous chapters or use the Chapter02
folder as the starting point.
You can find the source code for this chapter’s result at https://github.com/PacktPublishing/Web-Development-with-Blazor-Second-Edition/tree/main/Chapter03.
There are many ways of persisting data: document databases, relational databases, and files, to name a few. To remove complexity from the book, we will use the simplest way of creating blog posts by storing them as JSON in a folder.
The data will be accessible from both our Blazor WebAssembly project and the Blazor Server project, so we want to create a new project (not just put the code in one of the projects we created previously).
To save our blog posts, we will use JSON files stored in a folder, and to do so, we need to create a new project.
We can also create a new project from within Visual Studio (to be honest, that’s how I would do it), but to get to know the .NET CLI, let’s do it from the command line instead.
To create a new project, follow these steps:
MyBlog
folder.classlib
) by typing the following command:
dotnet new classlib -o Data
The dotnet
tool should now have created a folder called Data
.
dotnet new classlib -o Data.Models
dotnet sln add Data
dotnet sln add Data.Models
It will look for any solution in the current folder.
We call the projects Data
and Data.Models
so their purpose will be easy to understand and they will be easy to find.
The default project has a class1.cs
file – feel free to delete the file.
The next step is to create data classes to store our information.
Now we need to create a class for our blog post. To do that, we will go back to Visual Studio:
MyBlog
solution in Visual Studio (if it is not already open).
We should now have a new project called Data
in our solution. We might get a popup asking if we want to reload the solution; click Reload if so.
Data.Models
and select Add | New Folder. Name the folder Models
.Models
folder and select Add | Class. Name the class BlogPost.cs
and press Add.Models
folder and select Add | Class. Name the class Category.cs
and press Add.Models
folder and select Add | Class. Name the class Tag.cs
and press Add.Category.cs
and replace the content with the following code:
namespace Data.Models;
public class Category
{
public string? Id { get; set; }
public string Name { get; set; } = string.Empty;
}
The Category
class contains Id
and Name
. It might seem strange that the Id
property is a string, but this is because we will support multiple data storage types, including MSSQL, RavenDB, and JSON.
A string is a great datatype to support all of these. Id
is also nullable, so if we create a new Category
we send in null as an Id
.
Tag.cs
and replace the content with the following code:
namespace Data.Models;
public class Tag
{
public string? Id { get; set; }
public string Name { get; set; } = string.Empty;
}
The Tag
class contains an Id
and Name
.
BlogPost.cs
and replace the content with the following code:
namespace Data.Models;
public class BlogPost
{
public string? Id { get; set; }
public string Title { get; set; } = string.Empty;
public string Text { get; set; } = string.Empty;
public DateTime PublishDate { get; set; }
public Category? Category { get; set; }
public List<Tag> Tags { get; set; } = new();
}
In this class, we define the content of our blog post. We need an Id
to identify the blog post, a title, some text (the article), and the publishing date. We also have a Category
property in the class, which is of the Category
type. In this case, a blog post can have only one category, and A blog post can contain zero or more tags. We define the Tag
property with List<Tag>
.
By now, we have created a couple of classes that we will use. I have kept the complexity of these classes to a minimum since we are here to learn about Blazor.
Next, we will create a way to store and retrieve the information.
In this section, we will create an API. Since we are currently working with Blazor Server, we can access the database directly, so the API we create here will have a direct connection to the database:
Interfaces
.Interfaces
folder and select Add | Class.IBlogApi.cs
.IBlogApi.cs
and replace its content with the following:
namespace Data.Models.Interfaces;
public interface IBlogApi
{
Task<int> GetBlogPostCountAsync();
Task<List<BlogPost>?> GetBlogPostsAsync(int numberofposts, int startindex);
Task<List<Category>?> GetCategoriesAsync();
Task<List<Tag>?> GetTagsAsync();
Task<BlogPost?> GetBlogPostAsync(string id);
Task<Category?> GetCategoryAsync(string id);
Task<Tag?> GetTagAsync(string id);
Task<BlogPost?> SaveBlogPostAsync(BlogPost item);
Task<Category?> SaveCategoryAsync(Category item);
Task<Tag?> SaveTagAsync(Tag item);
Task DeleteBlogPostAsync(string id);
Task DeleteCategoryAsync(string id);
Task DeleteTagAsync(string id);
Task InvalidateCacheAsync();
}
The interface contains all the methods we need to get, save, and delete blog posts, tags, and categories.
Now we have an interface for the API with the methods we need to list blog posts, tags, and categories, as well as save (create/update) and delete them. Next, let’s implement the interface.
The idea is to create a class that stores our blog posts, tags, and categories as JSON files on our filesystem.
To implement the interface for the Blazor Server implementation, follow these steps:
Data
project and right-click on the Dependencies node. Select Add Project reference and check the Data.Models
project. Click OK.Microsoft.Extensions.Options
and click Install.BlogApiJsonDirectAccess.cs
.BlogApiJsonDirectAccess.cs
and replace the code with the following:
using Data.Models.Interfaces;
using Microsoft.Extensions.Options;
using System.Text.Json;
using Data.Models;
namespace Data;
public class BlogApiJsonDirectAccess: IBlogApi
{
}
The error list should contain many errors since we haven’t implemented the methods yet. We are inheriting from the IBlogApi
, so we know what methods to expose.
In the Data project, add a new class called BlogApiJsonDirectAccessSetting.cs
and replace its content with:
namespace Data;
public class BlogApiJsonDirectAccessSetting
{
public string BlogPostsFolder { get; set; } = string.Empty;
public string CategoriesFolder { get; set; } = string.Empty;
public string TagsFolder { get; set; } = string.Empty;
public string DataPath { get; set; } = string.Empty;
}
IOptions
is configured in program
during the configuration of dependencies and is injected into all the classes that ask for a specific type.
IOptions
. By getting the settings this way we don’t have to add any code – it can come from a database, a setting file, or even be hard coded. This is my favorite way to get settings because this part of the code itself doesn’t know how to do it – instead, we add all our configurations by using dependency injection.
Add the following code to the BlogApiJsonDirectAccess
class:
BlogApiJsonDirectAccessSetting _settings;
public BlogApiJsonDirectAccess(IOptions<BlogApiJsonDirectAccessSetting> option)
{
_settings = option.Value;
if (!Directory.Exists(_settings.DataPath))
{
Directory.CreateDirectory(_settings.DataPath);
}
if (!Directory.Exists($@"{_settings.DataPath}{_settings.BlogPostsFolder}"))
{
Directory.CreateDirectory($@"{_settings.DataPath}{_settings.BlogPostsFolder}");
}
if (!Directory.Exists($@"{_settings.DataPath}{_settings.CategoriesFolder}"))
{
Directory.CreateDirectory($@"{_settings.DataPath}{_settings.CategoriesFolder}");
}
if (!Directory.Exists($@"{_settings.DataPath}{_settings.TagsFolder}"))
{
Directory.CreateDirectory($@"{_settings.DataPath}{_settings.TagsFolder}");
}
}
We get the injected setting and ensure we have the correct folder structure for our data.
BlogApiJsonDirectAccess
class:
private List<BlogPost>? _blogPosts;
private List<Category>? _categories;
private List<Tag>? _tags;
private void Load<T>(ref List<T>? list, string folder)
{
if (list == null)
{
list = new();
var fullpath = $@"{_settings.DataPath}{folder}";
foreach (var f in Directory.GetFiles(fullpath))
{
var json = File.ReadAllText(f);
var bp = JsonSerializer.Deserialize<T>(json);
if (bp != null)
{
list.Add(bp);
}
}
}
}
private Task LoadBlogPostsAsync()
{
Load<BlogPost>(ref _blogPosts, _settings.BlogPostsFolder);
return Task.CompletedTask;
}
private Task LoadTagsAsync()
{
Load<Tag>(ref _tags, _settings.TagsFolder);
return Task.CompletedTask;
}
private Task LoadCategoriesAsync()
{
Load<Category>(ref _categories, _settings.CategoriesFolder);
return Task.CompletedTask;
}
The Load
method is a generic method that allows us to load blog posts, tags, and categories using the same method.
It will only load data from the filesystem if we don’t already have any data. We also add separate methods that load each type: LoadBlogpostsAsync
, LoadCategoriesAsync
, and LoadTagsAsync
.
SaveAsync
and DeleteAsync
. Add the following methods:
private async Task SaveAsync<T>(List<T>? list, string folder, string filename, T item)
{
var filepath = $@"{_settings.DataPath}{folder}{filename}";
await File.WriteAllTextAsync(filepath, JsonSerializer.Serialize<T>(item));
if (list == null)
{
list = new();
}
if (!list.Contains(item))
{
list.Add(item);
}
}
private void DeleteAsync<T>(List<T>? list, string folder, string id)
{
var filepath = $@"{_settings.DataPath}{folder}{id}.json";
try
{
File.Delete(filepath);
}
catch { }
}
These methods are also generic to share as much code as possible and avoid repeating the code for every type of class (BlogPost
, Category
, and Tag
).
public async Task<List<BlogPost>?> GetBlogPostsAsync(int numberofposts, int startindex)
{
await LoadBlogPostsAsync();
return _blogPosts ?? new();
}
public async Task<BlogPost?> GetBlogPostAsync(string id)
{
await LoadBlogPostsAsync();
if (_blogPosts == null)
throw new Exception("Blog posts not found");
return _blogPosts.FirstOrDefault(b => b.Id == id);
}
public async Task<int> GetBlogPostCountAsync()
{
await LoadBlogPostsAsync();
if (_blogPosts == null)
return 0;
else
return _blogPosts.Count();
}
The GetBlogPostsAsync
method takes a couple of parameters we will use later for paging. We execute the LoadBlogPostsAsync
at the start of each method to ensure we have loaded any data from our filesystem. This method will only be executed while the _blogposts
list (in this case) is null.
We also have a method that returns the current blog post count, which we will also use for paging.
public async Task<List<Category>?> GetCategoriesAsync()
{
await LoadCategoriesAsync();
return _categories ?? new();
}
public async Task<Category?> GetCategoryAsync(string id)
{
await LoadCategoriesAsync();
if (_categories == null)
throw new Exception("Categories not found");
return _categories.FirstOrDefault(b => b.Id == id);
}
The Category
methods don’t have any support for paging. Otherwise, they should look familiar as they do almost the same as the blog post methods.
public async Task<List<Tag>?> GetTagsAsync()
{
await LoadTagsAsync();
return _tags ?? new();
}
public async Task<Tag?> GetTagAsync(string id)
{
await LoadTagsAsync();
if (_tags == null)
throw new Exception("Tags not found");
return _tags.FirstOrDefault(b => b.Id == id);
}
As we can see, the code for tags is basically a copy of the one for categories.
Add the following code:
public async Task<BlogPost?> SaveBlogPostAsync(BlogPost item)
{
if (item.Id == null)
{
item.Id = Guid.NewGuid().ToString();
}
await SaveAsync<BlogPost>(_blogPosts, _settings.BlogPostsFolder, $"{item.Id}.json", item);
return item;
}
public async Task<Category?> SaveCategoryAsync(Category item)
{
if (item.Id == null)
{
item.Id = Guid.NewGuid().ToString();
}
await SaveAsync<Category>(_categories, _settings.CategoriesFolder, $"{item.Id}.json", item);
return item;
}
public async Task<Tag?> SaveTagAsync(Tag item)
{
if (item.Id == null)
{
item.Id = Guid.NewGuid().ToString();
}
await SaveAsync<Tag>(_tags, _settings.TagsFolder, $"{item.Id}.json", item);
return item;
}
The first thing we do is to check that the id
of the item is not null. If it is, we create a new Guid
. This is the id
of the new item. And this is also going to be the name of the JSON files stored on the filesystem.
public Task DeleteBlogPostAsync(string id)
{
DeleteAsync(_blogPosts, _settings.BlogPostsFolder, id);
if (_blogPosts != null)
{
var item = _blogPosts.FirstOrDefault(b => b.Id == id);
if (item != null)
{
_blogPosts.Remove(item);
}
}
return Task.CompletedTask;
}
public Task DeleteCategoryAsync(string id)
{
DeleteAsync(_categories, _settings.CategoriesFolder, id);
if (_categories != null)
{
var item = _categories.FirstOrDefault(b => b.Id == id);
if (item != null)
{
_categories.Remove(item);
}
}
return Task.CompletedTask;
}
public Task DeleteTagAsync(string id)
{
DeleteAsync(_tags, _settings.TagsFolder, id);
if (_tags != null)
{
var item = _tags.FirstOrDefault(b => b.Id == id);
if (item != null)
{
_tags.Remove(item);
}
}
return Task.CompletedTask;
}
The code we just added calls the DeleteAsync
method that deletes the item and will also remove the item from the collection.
Add the following method:
public Task InvalidateCacheAsync()
{
_blogPosts = null;
_tags = null;
_categories = null;
return Task.CompletedTask;
}
In the end, there will be three folders stored on the filesystem, one for blog posts, one for categories, and one for tags.
The next step is to add and configure the Blazor project to use our new storage.
We now have a way to access JSON files stored on our filesystem. In the repo on GitHub, you can find more ways of storing our data with RavenDB or SQL server, but be mindful to keep the focus on what is important (Blazor).
Now it’s time to add the API to our Blazor Server project:
Data
project. Open Program.cs
and add the following namespaces:
using Data;
using Data.Models.Interfaces;
builder.Services.AddOptions<BlogApiJsonDirectAccessSetting>()
.Configure(options =>
{
options.DataPath = @"......Data";
options.BlogPostsFolder = "Blogposts";
options.TagsFolder = "Tags";
options.CategoriesFolder = "Categories";
});
builder.Services.AddScoped<IBlogApi, BlogApiJsonDirectAccess>();
The snippet of code is the setting for where we want to store our files. You can change the data path property to where you want to store the files. Whenever we ask for IOptions<BlogApiJsonDirectAccessSetting>
, the dependency injection will return an object populated with the information we have supplied above. This is an excellent place to load configuration from our .NET configuration, a key vault, or a database.
We are also saying that when we ask for an IBlogAPI
we will get an instance of BlogApiJsonDirectAccess
back from our dependency injection. We will return to dependency injection in Chapter 4, Understanding Basic Blazor Components.
Now we can use our API to access the database in our Blazor Server project.
This chapter taught us how to create a simple JSON repository for our data. We also learned that other alternatives could be found in the GitHub repo if you want to look at other options.
We also created an interface to access the data, which we will use more throughout the book.
In the next chapter, we will learn about components, particularly the built-in components in Blazor templates. We will also create our first component using the API and repository we made in this chapter.