Flux state is supposed to be immutable, and that state replaced only by pure functions, which should only take input from their parameters when deciding what the output (new state) should be.
With this in mind, we need something that will enable us to access other sources of data such as web services, and then reduce the retrieved data into our state.
This tutorial will demonstrate how to dispatch an action to the store notifying it of our intention to fetch data from a server, and an effect that will perform the call to the server before reducing the result into state.
First we are going to create a simple service that will mock going to a web service and return some
data. We'll need to create the Shared
classes (data transfer objects) and the mock Services
that our
Effect
can use.
- Create a folder named
Shared
. Files in here would ordinarily be in a separate project for API objects to use when communicating with a server. - Within that folder create a
WeatherForecast.cs
file.
public class WeatherForecast
{
public DateTime Date { get; set; }
public int TemperatureC { get; set; }
public string Summary { get; set; }
public int TemperatureF => 32 + (int)(TemperatureC / 0.5556);
}
- Create a folder named
Services
. - Within that folder create a
WeatherForecastService.cs
file with the following interface and class in it. This will emulate retrieving data from a remote server.
using YourAppName.Shared;
public interface IWeatherForecastService
{
Task<WeatherForecast[]> GetForecastAsync(DateTime startDate);
}
public class WeatherForecastService : IWeatherForecastService
{
private static readonly string[] Summaries = new[]
{
"Freezing", "Bracing", "Chilly", "Cool", "Mild", "Warm", "Balmy", "Hot", "Sweltering", "Scorching"
};
public async Task<WeatherForecast[]> GetForecastAsync(DateTime startDate)
{
await Task.Delay(1000); // Simulate a 1 second response time
var rng = new Random();
return Enumerable.Range(1, 2).Select(index => new WeatherForecast
{
Date = startDate.AddDays(index),
TemperatureC = rng.Next(-20, 55),
Summary = Summaries[rng.Next(Summaries.Length)]
})
.ToArray();
}
}
- Finally, register the mock service in
Program.cs
.
services.AddScoped<IWeatherForecastService, WeatherForecastService>();
- Under the
Store
folder, create a new folder namedWeatherUseCase
. - Create a new state class to hold the state for this use-case.
using YourAppName.Store.Shared;
[FeatureState]
public class WeatherState
{
public bool IsLoading { get; }
public IEnumerable<WeatherForecast> Forecasts { get; }
private WeatherState() {} // Required for creating initial state
public WeatherState(bool isLoading, IEnumerable<WeatherForecast> forecasts)
{
IsLoading = isLoading;
Forecasts = forecasts ?? Array.Empty<WeatherForecast>();
}
}
This state holds a property indicating whether or not the data is currently being retrieved from
the server, and an enumerable holding zero to many WeatherForecast
objects.
- Inject
IState<WeatherState>
into ourApp
class
public class App
{
public class App
{
...
private readonly IState<WeatherState> WeatherState;
public App(
...
IState<WeatherState> weatherState)
{
...
WeatherState = weatherState;
WeatherState.StateChanged += WeatherState_StateChanged;
}
}
}
- Add the following code to output the current
WeatherState
to the console.
private void WeatherState_StateChanged(object sender, EventArgs e)
{
Console.WriteLine("");
Console.WriteLine("=========================> WeatherState");
Console.WriteLine("IsLoading: " + WeatherState.Value.IsLoading);
if (!WeatherState.Value.Forecasts.Any())
{
Console.WriteLine("--- No weather forecasts");
}
else
{
Console.WriteLine("Temp C\tTemp F\tSummary");
foreach (WeatherForecast forecast in WeatherState.Value.Forecasts)
Console.WriteLine($"{forecast.TemperatureC}\t{forecast.TemperatureF}\t{forecast.Summary}");
}
Console.WriteLine("<========================== WeatherState");
Console.WriteLine("");
}
- In the
Store
folder, create an empty classFetchDataAction
(this can remain empty). - Create a static
Reducers
class, which will setIsLoading
to true when ourFetchDataAction
action is dispatched.
public static class Reducers
{
[ReducerMethod]
public static WeatherState ReduceFetchDataAction(WeatherState state, FetchDataAction action) =>
new WeatherState(
isLoading: true,
forecasts: null);
}
Alternatively, because we aren't using any values from the FetchDataAction action
we
can declare our reducer method without that parameter, like so:
public static class Reducers
{
[ReducerMethod(typeof(FetchDataAction))]
public static WeatherState ReduceFetchDataAction(WeatherState state) =>
new WeatherState(
isLoading: true,
forecasts: null);
}
- In our
App
class add code to dispatch the action when option 2 is chosen.
public void Run()
{
...
Console.WriteLine("2: Fetch data");
...
switch(input.ToLowerInvariant())
{
case "1":
var incrementCounterActionction = new IncrementCounterAction();
Dispatcher.Dispatch(incrementCounterActionction);
break;
case "2":
var fetchDataAction = new FetchDataAction();
Dispatcher.Dispatch(fetchDataAction);
break;
case "x":
Console.WriteLine("Program terminated");
return;
}
...
}
Effect handlers cannot (and should not) affect state directly. They are triggered when the action they are interested in is dispatched through the store, and as a response they can dispatch new actions.
Effect handlers can be written in one of three ways.
- As with
[ReducerMethod]
, it is possible to use[EffectMethod]
without the action parameter being needed in the method signature.
[EffectMethod(typeof(FetchDataAction))]
public async Task HandleFetchDataAction(IDispatcher dispatcher)
{
var forecasts = await WeatherForecastService.GetForecastAsync(DateTime.Now);
dispatcher.Dispatch(new FetchDataResultAction(forecasts));
}
- By decorating instance or static methods with
[EffectMethod]
. The name of the class and the method are unimportant.
public class Effects
{
private readonly IWeatherForecastService WeatherForecastService;
public Effects(IWeatherForecastService weatherForecastService)
{
WeatherForecastService = weatherForecastService;
}
[EffectMethod]
public async Task HandleFetchDataAction(FetchDataAction action, IDispatcher dispatcher)
{
var forecasts = await WeatherForecastService.GetForecastAsync(DateTime.Now);
dispatcher.Dispatch(new FetchDataResultAction(forecasts));
}
}
- By descending from the
Effect<TAction>
class. The name of the class is unimportant, andTAction
identifies the type of action that should trigger thisEffect
.
public class FetchDataActionEffect : Effect<FetchDataAction>
{
private readonly IWeatherForecastService WeatherForecastService;
public FetchDataActionEffect(IWeatherForecastService weatherForecastService)
{
WeatherForecastService = weatherForecastService;
}
public override async Task HandleAsync(FetchDataAction action, IDispatcher dispatcher)
{
var forecasts = await WeatherForecastService.GetForecastAsync(DateTime.Now);
dispatcher.Dispatch(new FetchDataResultAction(forecasts));
}
}
These approaches work equally well, which you choose is an organisational choice. But keep in mind the following
- An
[EffectMethod]
can be declared either as static or instance. - If declared as an instance method, then an instance of the owning class will be created.
- Any dependencies that class requires will be injected, this means that multiple
[EffectMethod]
s can share property values (i.e. a CancellationToken).
I recommend you use approach 1 when you do not need to access values in the action object, otherwise use approach 2. Approach 3 is not recommended due to the amount of code involved.
- Create a new class
FetchDataResultAction
, which will hold the results of the call to the server so they can be "reduced" into our application state.
public class FetchDataResultAction
{
public IEnumerable<WeatherForecast> Forecasts { get; }
public FetchDataResultAction(IEnumerable<WeatherForecast> forecasts)
{
Forecasts = forecasts;
}
}
This is the action that is dispatched by our Effect
earlier, after it has retrieved the data from
our mock server.
- Edit the
Reducers.cs
class and add a new[ReducerMethod]
to reduce the contents of this result action into state.
[ReducerMethod]
public static WeatherState ReduceFetchDataResultAction(FetchDataResultAction action, WeatherState state) =>
new WeatherState(
isLoading: false,
forecasts: action.Forecasts);
This reducer simply sets the IsLoading
state back to false, and sets the Forecasts
state to the
values in the action that was dispatched by our effect.
The output of running the app should look something like the following:
Initializing store
1: Increment counter
2: Fetch data
x: Exit
> 2
=========================> WeatherState
IsLoading: True
--- No weather forecasts
<========================== WeatherState
1: Increment counter
2: Fetch data
x: Exit
>
=========================> WeatherState
IsLoading: False
Temp C Temp F Summary
8 46 Mild
-8 18 Sweltering
<========================== WeatherState
- Our
App
class dispatches theFetchDataAction
action to notify the store of our intent.
- The
ReduceFetchDataAction
reducer method setsIsLoading
to true, so our UI can reflect the change.
- The effect method is triggered by the
FetchDataAction
and asynchronously makes a data request to our mock server.
- The call to
Dispatcher.Dispatch(fetchDataAction)
in ourApp
class completes, so redisplays the menu options.
- One second later the mock service returns data.
- The effect method bundles the result data into a new
FetchDataResultAction
and dispatches the action.
- The
ReduceFetchDataResultAction
reducer methods setsIsLoading
to false, and setsForecasts
to the values in the action. - The store now has new state for
WeatherState
so executes itsStateChanged
event, resulting in the new state being output to the console below the options menu.