A collection of libraries to help manage state in Blazor applications.
For recent changes see CHANGELOG
Is an easy and light weight way of managing state in Blazor app. It is very similar to "Hooks" feature in React, but different.
To use Hooks, install BlazorState.Hooks
Nuget package.
Install-Package BlazorState.Hooks
or
dotnet add package BlazorState.Hooks
In the Startup.cs
add services.AddHooks();
to register necessary dependencies.
Inherit your component from HookedComponentBase
.
Define state using UseState
method in the markup.
@inherits HookedComponentBase
@{ var (count, setCount) = UseState(InitialCount); }
<p>Current count: @count</p>
<div class="form-group">
<button class="btn btn-primary" @onclick="() => setCount(count + 1)">+1</button>
<button class="btn btn-primary" @onclick="() => setCount(count - 1)">-1</button>
<button class="btn btn-danger" @onclick="() => setCount(0)">Reset</button>
</div>
In this case component state is created per component instance and disposed when component gets disposed.
See CounterOnHooks.razor for an example.
Allows the component state to be mapped to an entity outside the component's lifetime.
Inherit your component from PersistedHookedComponentBase<TState>
, provide type of the object used to store state.
Define state using UseState
method.
PersistedHookedComponentBase<TState>
will look for Props
property of type TState. You can override GetStateProperty
method to control the property used.
@inherits PersistedHookedComponentBase<PersistedCounterState>
@{ var (count, setCount) = UseState(s => s.Count); }
<p>Current count: @count</p>
<div class="form-group">
<button class="btn btn-primary" @onclick="() => setCount(count + 1)">+1</button>
<button class="btn btn-primary" @onclick="() => setCount(count - 1)">-1</button>
<button class="btn btn-danger" @onclick="() => setCount(0)">Reset</button>
</div>
@code {
[Parameter]
public PersistedCounterState Props { get; set; }
}
By default, value is immediately set to the backing entity property, this behavior can be changed by calling DeferStatePersistans
method in the component initializer. Later when ready to synchronize state with a backing object, call Persist
method.
See, CounterOnHooksDeferredPersisted for an example.
All samples can be found here samples.
As the name suggests it is a port of React-Redux library to Blazor/.NET world.
Redux is a popular library for managing state in SPA applications. Key benefits of using Redux:
- Single source of truth. The whole state of the application is in one place, the store.
- It helps to enforce unidirectional data flow in the application, thus making state mutations more predictable and easier to understand.
- It helps separate presentational components from container components (state aware components).
- Great DevTools makes it easy to see how the state of the application is changing in time.
More on Redux here.
Refer to samples in the repository for usage examples.
Install BlazorState.Redux
from NuGet
Install-Package BlazorState.Redux
or
dotnet add package BlazorState.Redux
The state in Redux is a tree. Here is an example of the state object for a sample application:
public class RootState
{
public int Count { get; set; }
public WeatherState Weather { get; set; }
}
where WeatherState
is:
public class WeatherState
{
public WeatherState(IEnumerable<WeatherForecast> forecasts)
{
Forecasts = forecasts;
}
public IEnumerable<WeatherForecast> Forecasts { get; private set; }
}
Note: Name RootState
is completely arbitrary and can be anything.
In Redux, state is immutable, meaning that if it needs to change a new state object is created. For that purpose, WeatherState
does not expose setters for its properties.
RootState
change is handled by an out of the box reducer and must have a default constructor.
When state shape is defined, some configuration needs to be put in place for Redux to work.
In your services configuration (Startup.cs
or Program.cs
) add the following line:
services.AddReduxStore<RootState>(cfg =>
{
// TODO: Configure reducers and actions
});
This registers all services needed for Redux to function properly. Also, it provides a configurator that can be used to configure reducers, actions, and other options.
See Configuration
section below.
Next, in App.razor
add a BlazorRedux
component before Router
component.
<BlazorRedux />
<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>
For the component to be found @using BlazorState.Redux.Blazor
statement needs to be added to _Imports.razor
.
BlazorRedux
component is responsible for bootstrapping Redux store and dev tools, if enabled.
As in React-Redux
there are also two kinds of component in Blazor-Redux
.
First, is presentational component, this component does not have access to the application state and takes all it needs from props
object that is passed to it. Similarly, the outgoing communication is also done through callbacks, that are passed with props
. In simple words, this component only renders data that is passed to it.
Here is an example of the Counter
presentational component:
<h1>Counter</h1>
<p>Current count: @Props.Count</p>
<button class="btn btn-primary" @onclick="Props.IncrementByOne">+1</button>
<button class="btn btn-primary" @onclick="() => Props.IncrementBy.InvokeAsync(5)">+5</button>
<button class="btn btn-primary" @onclick="() => Props.IncrementBy.InvokeAsync(10)">+10</button>
<button class="btn btn-primary" @onclick="Props.DecrementByOne">-1</button>
<button class="btn btn-primary" @onclick="() => Props.DecrementBy.InvokeAsync(5)">-5</button>
<button class="btn btn-primary" @onclick="() => Props.DecrementBy.InvokeAsync(10)">-10</button>
<button class="btn btn-danger" @onclick="Props.Reset">Reset</button>
@code {
[Parameter] public CounterProps Props { get; set; }
}
where CounterProps
is
public class CounterProps
{
public int Count { get; set; }
public EventCallback IncrementByOne { get; set; }
public EventCallback<int> IncrementBy { get; set; }
public EventCallback DecrementByOne { get; set; }
public EventCallback<int> DecrementBy { get; set; }
public EventCallback Reset { get; set; }
}
Second, is container component, or connected component. It is called connected, because it knows about the store and it is responsible for mapping the state to the props and dispatching actions in response to presentational component callbacks.
Here is an example of the CounterConnected
component:
public class CounterConnected : ConnectedComponent<Counter, RootState, CounterProps>
{
protected override void MapStateToProps(RootState state, CounterProps props)
{
props.Count = state?.Count ?? 0;
}
protected override void MapDispatchToProps(IStore<RootState> store, CounterProps props)
{
props.IncrementByOne = EventCallback.Factory.Create(this, () =>
{
store.Dispatch(new IncrementByOneAction());
});
props.IncrementBy = EventCallback.Factory.Create<int>(this, amount =>
{
store.Dispatch(new IncrementByAction { Amount = amount });
});
props.DecrementByOne = EventCallback.Factory.Create(this, () =>
{
store.Dispatch(new DecrementByOneAction());
});
props.DecrementBy = EventCallback.Factory.Create<int>(this, amount =>
{
store.Dispatch(new DecrementByAction { Amount = amount });
});
props.Reset = EventCallback.Factory.Create(this, () =>
{
store.Dispatch(new ResetCountAction());
});
}
protected async override Task Init(IStore<RootState> store)
{
// Optional
}
}
Connected component must inherit from ConnectedComponent
and provide 3 type arguments:
- Presentational component to connect
- Type of the application state (Root state)
- Props type of the presentational component
ConnectedComponent
base class defines two abstract methods that need to be implemented:
- MapStateToProps - method responsible for mapping state object to presentational component
props
object. This method is called every time the state changes. - MapDispatchToProp - method responsible for mapping presentational components callbacks to dispatch methods. This method is called once, when component initializes.
Additionally, there is an Init method that can be used to do data fetching or some other initialization logic. See '['Async actions' for more details.
Actions are payloads of information that send data from your application to your store. They are the only source of information for the store. Send them to the store using store.Dispatch()
.
Example of IncrementByAction
action:
public class IncrementByAction : IAction
{
public int Amount { get; set; }
}
Actions must implement IAction
interface, otherwise they are Plain Old CLR Objects classes.
Reducers specify how the application's state changes in response to actions sent to the store.
Here is an example of the CountReducer
:
public class CountReducer : IReducer<int>
{
public int Reduce(int state, IAction action)
{
switch (action)
{
case IncrementByOneAction _:
return state + 1;
case DecrementByOneAction _:
return state - 1;
case IncrementByAction a:
return state + a.Amount;
case DecrementByAction a:
return state - a.Amount;
case ResetCountAction _:
return 0;
default:
return state;
}
}
}
Reducer must implement IReducer<TState>
interface, where TState
is a type of state this particular reducer handles.
In the example above, the state this reducer handles is of type int
, but it can be any C# object.
It is important to remember that reducer is a pure function, it should not produce side effects. In case of a change reducer must always return new state and should never modify existing state.
For example, WeatherReducer
will look like this:
public class WeatherReducer : IReducer<WeatherState>
{
private static Random random = new Random();
public WeatherState Reduce(WeatherState state, IAction action)
{
switch (action)
{
case ReceiveWeatherForecastsAction a:
return new WeatherState(a.Forecasts);
case AddRandomForecast a:
var forecasts = new List<WeatherForecast>(state.Forecasts);
forecasts.Add(new WeatherForecast
{
Date = DateTime.Today.AddDays(random.Next(1, 30)),
Summary = $"There is {random.Next(0, 100)}% chance of rain.",
TemperatureC = random.Next(10, 40)
});
return new WeatherState(forecasts);
default:
return state;
}
}
}
Once reducers defined, they need to be mapped to the corresponding state property that they handle. This is done in Startup.cs
using config object of the AddReduxStore
method:
services.AddReduxStore<RootState>(cfg =>
{
cfg.Map<CountReducer, int>(s => s.Count);
cfg.Map<WeatherReducer, WeatherState>(s => s.Weather);
});
Note: Reducer must have a default parameter-less constructor.
When all necessary components are in place, it is time to place component(s) on the page.
This is typically done by using static Get
method on the connected component. See section Defining components
for more details. This method returns RenderFragment
that can be directly rendered on the page. Clean and simple.
Here is an example of the Counter
page rendering Counter
component.
@page "/counter"
<CounterConnected />
To configure DevTools, add cfg.UseReduxDevTools();
to the configuration callback in Startup.cs
:
services.AddReduxStore<RootState>(cfg =>
{
cfg.UseReduxDevTools();
cfg.Map<CountReducer, int>(s => s.Count);
cfg.Map<WeatherReducer, WeatherState>(s => s.Weather);
});
Assuming that ReduxDevTools is installed, open your browser of choice developer tools and enjoy time travel debugging and other goodness of ReduxDevTools.
Actions we've seen so far were synchronous, but sooner or later application needs to make asynchronous request for data to the server. For such a case BlazorState.Redux has a concept of async actions.
Here is an example of async action fetching weather information from the server:
public class FetchWeather : IAsyncAction
{
private readonly HttpClient _http;
public FetchWeather(HttpClient http)
{
_http = http;
}
public async Task Execute(IDispatcher dispatcher)
{
var forecasts = await _http.GetJsonAsync<WeatherForecast[]>("sample-data/weather.json");
dispatcher.Dispatch(new ReceiveWeatherForecastsAction
{
Forecasts = forecasts
});
}
}
Async action must implement IAsyncAction
or IAsyncAction<TParam>
interface. Both interfaces have only one method, Execute
, the difference is that IAsyncAction<TParam>
expect a second parameter. This parameter is a user-defined object of any type.
For example:
public class DeleteIdentity : IAsyncAction<IdentityViewModel>
{
private readonly HttpClient _client;
public DeleteIdentity(HttpClient client)
{
_client = client;
}
public async Task Execute(IDispatcher dispatcher, IdentityViewModel identity)
{
await _client.Delete<IdentityViewModel>(identity.Id);
await dispatcher.Dispatch<FetchIdentities>();
}
}
To dispatch async action, use the store.Dispatch<TAsyncAction>
method.
private async Task Init(IStore<RootState> store)
{
await store.Dispatch<FetchWeather>();
}
Last, but not least, actions must be registered in Startup.cs
.
There are two options:
- Register each action separately.
cfg.RegisterAsyncAction<WeatherForecast>();
- Register all actions in assembly.
cfg.RegisterActionsFromAssemblyContaining<FetchWeather>();
services.AddReduxStore<RootState>(cfg =>
{
cfg.RegisterActionsFromAssemblyContaining<FetchWeather>();
});
In some cases it is desirable to store current page address in the state. Once such case might be to navigate user back to the same page where he left during last session.
This is supported by Redux out of the box, and can be configured by adding cfg.TrackUserNavigation(s => s.Location);
to Startup.cs
:
services.AddReduxStore<RootState>(cfg =>
{
cfg.TrackUserNavigation(s => s.Location);
});
s => s.Location
is the property on the RootState. This property must be of type string.
When configured, special NavigationAction
will be dispatched every time user navigates to another page.
If state is persisted, Redux will handle navigating user back to the page stored in the state on the first request.
This is done automatically by Redux, the only action required is configuring state storage.
BlazorState.Redux has out of the box implementation that uses browser Local Storage to persist and restore state. To use it, install Blazor.Redux.Storage
NuGet package and add cfg.UseLocalStorage();
to the config callback in Startup.cs
:
services.AddReduxStore<RootState>(cfg =>
{
cfg.UseLocalStorage();
});