State Management
App UI provides an additional assembly called Unity.AppUI.Redux
that contains a set of classes that implement
the Redux pattern.
The Redux pattern is a way to manage the state of your application. It is a pattern that is used in many different frameworks and libraries, especially in the JavaScript ecosystem.
The Redux pattern is based on the following principles:
- The state of your application is stored in a single object called the
store
. - The only way to change the state is to dispatch an
action
to thestore
. - To specify how the state tree is transformed by actions, you write pure
reducers
. - The
store
is created by combining thereducers
into a single reducer function. - The
store
has a singledispatch
method that accepts anaction
and returns astate
object. - The
store
has a singlegetState
method that returns the currentstate
object. - The
store
has a singlesubscribe
method that accepts a callback that is called every time thestate
changes.
For more extensibility, App UI includes the concept of Slice. A slice is a part of the state tree that is managed by a specific reducer. A slice can be used to manage a specific part of the application state, such as the state of a specific screen. It is also useful to monitor changes on a specific part of the state tree. This approach is similar to what offers Redux Toolkit.
Components
Store
The Store class is the main entry point for the Redux pattern.
It is responsible for creating the store
and dispatching actions to it.
Reducer
A reducer is a pure function that takes the current state and an action as parameters and returns the new state. The user has to create Reducers as pure functions inside the application code. You can check the Simple Counter example to see how to create a reducer.
Slice
A Slice is a part of the state tree that is managed by a specific reducer. You can add multiple slices to the store. Each slice has a unique name that is used to identify it.
Action Creator And Action
An action creator is a method that is used to create an action. An action is an object that is dispatched to the store. The action creator method can be created using the CreateAction utility method.
To get an action instance from the Actioncreator, you can call the Invoke
method on the action creator, and eventually
pass the action parameters to it (if any).
You can then dispatch the action to the store using the Dispatch method.
Async Thunk
An async thunk is a function that can be dispatched to the store. It is used to perform asynchronous operations, such as fetching data from a server. The async thunk function can be created using the CreateAsyncThunk utility method.
// Example of State for the Redux Store.
record MyState
{
public string value { get; set; } = null;
public string status { get; set; } = "idle";
}
// An example of a long operation that will return the same string value as
// the one passed as argument, but with 250ms of delay.
async Task<string> MyLongOperation(string arg, ThunkAPI<string,string> api, CancellationToken token)
{
await Task.Delay(250, token);
return arg;
}
// Configure the Redux Store.
var store = new Store();
var asyncThunk = Store.CreateAsyncThunk("myAsyncThunk", MyLongOperation);
store.CreateSlice("mySlice", new MyState(), null, builder =>
{
// In the extra reducers, you can link a reducer to sub-actions generated by the AsyncThunkActionCreator.
// Available sub-actions are: pending/rejected/fulfilled.
builder.AddCase(asyncThunk.pending, (state, action) => state with { value = null, status = "pending" });
builder.AddCase(asyncThunk.rejected, (state, action) => state with { value = null, status = "rejected" });
builder.AddCase(asyncThunk.fulfilled, (state, action) => state with { value = action.payload, status = "done" });
});
// You can now kick start your long operation by dispatching your AsyncThunkAction.
var action = asyncThunk.Invoke("My Thunk Argument");
// You can use *await* on the Dispatch method if you want to wait for operation completion too.
await store.DispatchAsyncThunk(action);
var state = store.GetState<MyState>("mySlice");
Assert.AreEqual("My Thunk Argument", state.value);
Assert.AreEqual("done", state.status);
Middleware
Middlewares are not yet supported in App UI, but they will be added in the future.
Examples
Simple Counter
First, create the state object that will be used by the store. This object will contain the current value of the counter.
You can take advantage of the new C# 9.0 feature called Records.
The record
type is a reference type that is immutable by default. It is a good fit for the state object.
public record CounterState
{
public int Count { get; init; } = 0;
}
Then, create a reducer method that will be used to update the state. The reducer method is a pure function that takes
the current state and an action as parameters and returns the new state. The reducer method is responsible for updating
the state based on the action type. In this example, the reducer method will only handle the Increment
action.
Since when building the Store you can tie a reducer to a specific action type, you don't have to to check the action type inside the reducer method. The reducer method will only be called when the action type matches the one that is tied to the reducer.
public static CounterState IncrementReducer(CounterState state, Increment action)
{
return state with { Count = state.Count + 1 };
}
Now, create an action creator method that will be used to create the Increment
action. The action creator method can be created
using CreateAction utility method.
The action creator method will be used to create the Increment
action that will be dispatched to the store.
public static Actions
{
public const string Increment = "counter/Increment";
}
public static readonly ActionCreator Increment = Store.CreateAction(Actions.Increment);
Finally, create the store and subscribe to the state changes. The store is created by passing the reducer method to the
Store method.
The store is responsible for calling the reducer method when an action is dispatched to it. The store also provides a
Subscribe
method that accepts a callback that is called every time the state changes.
The callback is called with the new state as a parameter.
var store = new Store();
store.CreateSlice<CounterState>("counter", new CounterState(), builder => {
builder.Add(Actions.Increment, IncrementReducer);
});
var unsubscriber = store.Subscribe<CounterState>("counter", state => {
Debug.Log($"Counter value: {state.Count}");
});
Now, you can dispatch the Increment
action to the store. The store will call the reducer method and update the state.
store.Dispatch(Increment.Invoke());
To unsubscribe from the state changes, call the unsubscriber
method that was returned by the
Subscribe method.
unsubscriber();
Using the Store inside the MVVM Pattern
Note
This example is using the MVVM pattern. If you are not familiar with the MVVM pattern, you can read the MVVM documentation first.
The Store class can be used inside the MVVM pattern, as a service.
First, create a service that will be used to access the store. The service will be responsible for dispatching actions to the store and subscribing to the state changes.
public interface IStoreService
{
Store Store { get; }
}
public class StoreService
{
public Store Store { get; }
public StoreService()
{
Store = new Store();
}
}
Then, register the service inside your custom UIToolkitAppBuilder implementation.
public class MyAppBuilder : UIToolkitAppBuilder<MyApp>
{
protected override void OnConfiguringApp(AppBuilder builder)
{
base.OnConfiguringApp(builder);
builder.services.AddSingleton<IStoreService, StoreService>();
// Add others services/viewmodels/views here...
}
}
Now, you can access the store inside your viewmodels. The store can be injected via constructor injection.
public class MyViewModel : ObservableObject
{
readonly IStoreService m_StoreService;
public MyViewModel(IStoreService storeService)
{
m_StoreService = storeService;
// Subscribe to the state changes etc...
}
}
Asynchronous Operations
In the MVVM pattern, asynchronous operations can be performed inside the viewmodel via the Command pattern. The AsyncRelayCommand class can be used to create a command that can perform asynchronous operations.
using Unity.AppUI.Core;
using Unity.AppUI.UI;
using Unity.AppUI.MVVM;
// ViewModel
public class MyViewModel : ObservableObject
{
readonly IStoreService m_StoreService;
public MyViewModel(IStoreService storeService)
{
m_StoreService = storeService;
// Subscribe to the state changes etc...
}
public ICommand IncrementCommand => new AsyncRelayCommand(Increment);
async Task Increment(CancellationToken token)
{
m_StoreService.Store.Dispatch(IncrementPendingAction.Invoke());
await Task.Delay(1000, token);
m_StoreService.Store.Dispatch(IncrementAction.Invoke());
m_StoreService.Store.Dispatch(IncrementFulfilledAction.Invoke());
}
}
// View
public class MyView : VisualElement
{
public MyView(MyViewModel viewModel)
{
var button = new Button();
button.clicked += viewModel.IncrementCommand.Execute;
viewModel.IncrementCommand.CanExecuteChanged +=
(sender, e) => button.SetEnabled(((AsyncRelayCommand)sender).CanExecute());
Add(button);
}
}
If you want to perform asynchronous operations without the Command pattern, you can use the CreateAsyncThunk method to create an async thunk that can be dispatched to the store.
using Unity.AppUI.Core;
using Unity.AppUI.UI;
using Unity.AppUI.MVVM;
// ViewModel
public class MyViewModel : ObservableObject
{
readonly IStoreService m_StoreService;
public MyViewModel(IStoreService storeService)
{
m_StoreService = storeService;
// Subscribe to the state changes etc...
m_StoreService.Store.Subscribe<MyState>("mySlice", state => {
CanIncrement = state.CanIncrement;
});
}
private bool m_CanIncrement;
public bool CanIncrement
{
get => m_CanIncrement;
set => SetProperty(ref m_CanIncrement, value);
}
public async Task IncrementAsync()
{
var action = IncrementAsyncThunk.Invoke(1);
await m_StoreService.Store.DispatchAsyncThunk(action);
}
}
// View
public class MyView : VisualElement
{
readonly Button m_Button;
public MyView(MyViewModel viewModel)
{
m_Button = new Button();
m_Button.clicked += () => viewModel.IncrementAsync();
Add(m_Button);
viewModel.PropertyChanged += OnViewModelPropertyChanged;
}
private void OnViewModelPropertyChanged(object sender, PropertyChangedEventArgs e)
{
var viewModel = (MyViewModel)sender;
if (e.PropertyName == nameof(MyViewModel.CanIncrement))
button.SetEnabled(viewModel.CanIncrement);
}
}