This library offers Redux-style state management for Blazor, with support for both C# and F#. The implementation is based on Redux.NET.
The combination of Blazor and Redux becomes an incredibly compelling platform for frontend development—more compelling than any other alternative, if you ask me:
StateHasChanged()
.with
keyword in record types makes it simple to work with immutable types in your reducer logic. Not to mention that a model with many small types can be created with much less ceremony. F# lends itself well to type driven development. However, the Blazor project itself and the Razor pages must be C#.Follow these steps, or just open samples.sln
in this repository and take a look.
Assuming you have the Blazor requirements installed, create a new Blazor project.
Add the Blazor-Redux NuGet package.
Add some types and actions, and a reducer. See below for details.
Open Program.cs
and configure the Redux store in the BrowserServiceProvider
:
configure.AddReduxStore<MyState, IAction>(new MyState(), Reducers.RootReducer);
You will need to add
using BlazorRedux;
You can basically just add the classes you need, typically with properties. Here's a sample state class:
public class MyState
{
public int Count { get; set; }
public IEnumerable<WeatherForecast> Forecasts { get; set; }
}
Add an F# class library, and add a reference to it from your Blazor project. You can use F# record types, such as this:
type MyState =
{
Count: int;
Forecasts: WeatherForecast list option;
}
It is recommended that you collect all your actions in a file called Actions.cs
. An action can be as simple as:
public class IncrementByOneAction : IAction {}
The actions need to have a common base type, which is the one you configure on your store in Program.cs
. You can use the provided marker interface IAction
.
You can also have properties on your actions:
public class ReceiveWeatherAction : IAction
{
public IEnumerable<WeatherForecast> Forecasts { get; set; }
}
And you can take constructor arguments if you like:
public class IncrementByValueAction : IAction
{
public IncrementByValueAction(int value)
{
Value = value;
}
public int Value { get; set; }
}
This is where discriminated union types really shine. Just define your actions this way:
type MyMsg =
| IncrementByOne
| IncrementByValue of n : int
| ClearWeather
| ReceiveWeather of r : WeatherForecast list
The common action type is now MyMsg
, so you will initialize your store in Program.cs
like this:
configure.AddReduxStore<MyState, MyMsg>(
new MyState(0, null),
MyFuncs.MyReducer);
You can define your reducer logic as follows. Note that we use pattern matching to easily cast the action type and extract parameters such as the Value
property on IncrementByValueAction
.
public static int CountReducer(int count, IAction action)
{
switch (action)
{
case IncrementByOneAction _:
return count + 1;
case IncrementByValueAction a:
return count + a.Value;
default:
return count;
}
}
You can have one big reducer with cases for each of your application actions, or you can break them up and create a root reducer:
public static MyState RootReducer(MyState state, IAction action)
{
return new MyState
{
Count = CountReducer(state.Count, action),
Forecasts = ForecastsReducer(state.Forecasts, action)
};
}
Since C# doesn't have as good support for dealing with immutable types as F# does, breaking up the reducers is a decent way to handle it. It's good practice to leave the incoming state unchanged, and rather return a new object as demonstrated here.
In F#, a reducer may look like this:
let MyReducer state action =
match action with
| IncrementByOne -> { state with Count = state.Count + 1 }
| IncrementByValue n -> { state with Count = state.Count + n }
| ClearWeather -> { state with Forecasts = None }
| ReceiveWeather r -> { state with Forecasts = Some r }
Thanks to the with
keyword, it is easy to create a copy of the state and change some parts of it, without mutating the incoming state. So there is less reason to break the reducer into smaller parts, although you can if you like.
Time to connect the Redux store with the Blazor components and actually output some HTML. It's helpful if you first define a common base class with your state and action types plugged in:
using BlazorRedux;
public class MyAppComponent : ReduxComponent<MyState, IAction>
{
}
Here's a basic Blazor page:
@page "/counter"
@inherits MyAppComponent
@using BlazorStandalone
<p>Current count: @State.Count</p>
In your Blazor components, you can dispatch actions like this:
<button onclick=@(() => Dispatch(new IncrementByValueAction(3)))>Click me</button>
You can also dispatch actions from C# code blocks, such as in OnInit()
methods:
@functions
{
protected override void OnInit()
{
Dispatch(new IncrementByValueAction(3));
}
}
None of the methods we have seen so far have been async
. How do we handle asynchronous tasks such as loading data from a backend? By using action creators. They are called that because they can dispatch several actions during the course of a long-running operation. Here's an example:
public static class ActionCreators
{
public static async Task LoadWeather(Dispatcher<IAction> dispatch, HttpClient http)
{
dispatch(new ClearWeatherAction());
var forecasts = await http.GetJsonAsync<WeatherForecast[]>(
"/sample-data/weather.json");
dispatch(new ReceiveWeatherAction
{
Forecasts = forecasts
});
}
}
You can dispatch the operation from a component:
@inherits MyAppComponent
@inject HttpClient Http
@using System.Threading.Tasks
@functions
{
protected override async Task OnInitAsync()
{
await ActionCreators.LoadWeather(Store.Dispatch, Http);
}
}
If you prefer to keep your F# code pure, and just use it to manage your types, actions and reducers, you can certainly keep the action creators in C#. But you can also implement them in F#:
module ActionCreators =
open System.Net.Http
open FSharp.Control.Tasks
let LoadWeather (dispatch: Dispatcher<MyMsg>, http: HttpClient) =
task {
dispatch.Invoke(MyMsg.ClearWeather) |> ignore
let! forecastString = http.GetStringAsync("/sample-data/weather.json") |> Async.AwaitTask
let forecasts: WeatherForecast list =
forecastString
|> Json.parse
|> Json.deserialize
dispatch.Invoke(MyMsg.ReceiveWeather forecasts) |> ignore
}
The task
computation expression requires the NuGet package TaskBuilder.fs
. Also note that we are not using GetJsonAsync
here, because SimpleJson doesn't understand F# record types. Instead we just download the JSON data as a string, and use Chiron to deserialize it. This means we have to add serialization instructions to our record types. See samples.sln
for details. Alternatively, you could use classes in F# instead of record types. A JSON serialization strategy is needed not just for accessing web APIs, but also for exchanging state data with the Redux DevTools, as we will see in the next section.
Using the Redux DevTools, you can monitor all actions and state changes going on in your app, time travel to any state in the history, save and load state, and reset the application state.
The DevTools can also take care of importing the current state whenever you reload your app. This is similar to Hot Module Replacement, where you can make changes to the app and have the new version automatically repopulate the application state. This is very productive when working on a part of your app that requires a lot of actions in order to get to.
To connect with the Redux DevTools, all you have to do is to add
@ReduxDevTools
somewhere, such as in the header of App.cshtml
.
For F#, you will need to help out with the JSON deserialization, because SimpleJson doesn't understand F# record types, and Mono for WebAssembly doesn't currently support Reflection.Emit
, which Json.NET and some other libraries depend on. See samples.sln
for how to handle JSON using Chiron. You will have to connect your serialization helpers in Program.cs
like this:
configure.AddReduxStore<MyState, MyMsg>(
new MyState("", 0, null),
MyFuncs.MyReducer,
options =>
{
options.StateSerializer = MyFuncs.StateSerializer;
options.StateDeserializer = MyFuncs.StateDeserializer;
});
If you try time traveling with Redux DevTools now, you will see that you can move back and forth and observe how the app has responded to state changes. But if you have been visiting several pages in your app, the time traveling doesn't show you that. It will stay on whichever page you are at. No problem, because Blazor Redux supports state-aware routing, as long as you decide how to store the location in your state, and give Blazor Redux access to that.
The location data is typically just an additional string in your state:
public class MyState
{
public string Location { get; set; }
public int Count { get; set; }
...
}
You also need to tell Blazor Redux where to find this location data. This enables automatic navigation whenever you change the location state. You do this in Program.cs
:
configure.AddReduxStore<MyState, IAction>(
new MyState(), Reducers.RootReducer, options =>
{
options.GetLocation = state => state.Location;
});
Next, make sure you add Location.Reducer
to your RootReducer
:
public static MyState RootReducer(MyState state, IAction action)
{
return new MyState
{
Location = Location.Reducer(state.Location, action),
Count = CountReducer(state.Count, action),
...
};
}
This will work as long as your RootReducer
operates on IAction
. If it doesn't, follow the approach described in the F# section below.
In F# your actions will typically be a discriminated union type and not implement IAction
, hence the approach is a little different.
You will still add the location as a string to your state:
type MyState =
{
Location: string
Count: int
...
}
Next, you will implement a separate LocationReducer
:
let LocationReducer state (action: NewLocationAction) =
{ state with Location = action.Location }
Now you need to tell Blazor Redux about the location data and reducer. You do that in Program.cs
like this:
configure.AddReduxStore<MyState, MyMsg>(
new MyState("", 0, null),
MyFuncs.MyReducer,
options =>
{
options.LocationReducer = MyFuncs.LocationReducer;
options.GetLocation = state => state.Location;
});
Blazor Redux is at an experimental stage, and you should expect breaking changes. But I'd be very interested in discussing the design and potential features. Please open an issue if you have any particular topic in mind.