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.
App UI offers a generic type Store<TStore> to represent the store.
The Store<TStore> class is a generic class that accepts the type of the state
object as a type parameter. The state object is a class that contains the current state of the application.
You can use directly the generic version of the Store
class to support your very own type of state.
using Unity.AppUI.Redux;
record MyState {}
var store = Store.CreateStore<MyState>((state, action) => state, new MyState());
If you want to support slices in your state, we recommend to use our PartitionedState class as state type.
Example of creating a Store with slices:
using Unity.AppUI.Redux;
record MyState {}
var store = StoreFactory.CreateStore(new []
{
StoreFactory.CreateSlice(
"mySlice",
new MyState(),
builder => { /* ... */ },
),
});
Example of using the IPartionableState
interface:
using Unity.AppUI.Redux;
class MyState : IPartionableState<MyState>
{
public TSliceState Get<TSliceState>(string sliceName)
{
// Implement the method to return the slice state.
}
public MyState Set<TSliceState>(string sliceName, TSliceState sliceState)
{
// Implement the method to set the slice state.
// Remember that the state object is immutable, so the returned object should be a new instance.
}
}
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 ActionCreator is the type used to create an action.
An Action is an object that is dispatched to the store. It can contain a type and a payload.
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 creator can be constructed using the AsyncThunkCreator.
// 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;
}
// Create an AsyncThunkCreator that will be used to dispatch the long operation.
var asyncThunk = new AsyncThunkCreator("myAsyncThunk", MyLongOperation);
// Configure the Redux Store.
var store = Store.CreateStore(new []
{
Store.CreateSlice(
"mySlice",
new MyState(),
null,
builder =>
{
// In the extra reducers, you can link a reducer to sub-actions generated by the AsyncThunkCreator.
// 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");
// OR 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);
Enhancer
Enhancers are used to extend the functionality of the store, such as modifying its dispatcher and reducer components.
Here is a simple example of how to create a logger enhancer that logs every action that is dispatched to the store.
using Unity.AppUI.Redux;
public static class Enhancers
{
public StoreEnhancer<TState> LoggerEnhancer<TState>()
{
return (createStore) => (reducer, initialState) =>
{
var store = createStore(reducer, initialState);
var originalDispatch = store.dispatch;
store.dispatch = action =>
{
Debug.Log($"Action: {action.type}");
originalDispatch(action);
};
return store;
};
}
}
Middleware
Middleware is a function that is called before the action is dispatched to the store. Middleware can be used to perform side effects, such as logging, or to modify the action before it is dispatched to the store.
To compose your middlewares and use it as an enhancer, you can use the Store.ApplyMiddleware method.
Here is an example of how to create a simple logger middleware that logs every action that is dispatched to the store.
using Unity.AppUI.Redux;
public static class Application
{
public static Middleware<TStore,TStoreState> LoggerMiddleware<TStore,TStoreState>()
where TStore : Store<TStoreState>
{
return (store) => (nextMiddleware) => (action) =>
{
Debug.Log($"Action: {action.type}");
return nextMiddleware(action);
};
}
public static StoreEnhancer<TStore,TStoreState> EnhanceStoreWithLogger<TStore,TStoreState>()
where TStore : Store<TStoreState>
{
return Store.ApplyMiddleware(LoggerMiddleware<TStore,TStoreState>());
}
static void Main()
{
// At this point you can use your Enhancer to create a new store with the logger middleware.
var store = Store.CreateStore(new []
{
Store.CreateSlice(
"mySlice",
new MyState(),
builder => { /* ... */ }),
}, EnhanceStoreWithLogger());
}
}
DevTools
The Redux DevTools is a debugging tool that allows you to inspect the state of your application and track the actions that are dispatched to the store. The Redux DevTools is available in Unity Editor under the Window > App UI > Redux DevTools menu.
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 can be constructed
using ActionCreator) type.
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 = Actions.Increment; // implicit construction using string value.
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 = StoreFactory.CreateStore(new []
{
StoreFactory.CreateSlice(
"counter",
new CounterState(),
builder => {
builder.Add(Actions.Increment, IncrementReducer);
}),
});
var subscription = 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 subscription's Dispose
method that was returned by the
Subscribe method.
subscription.Dispose();
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 = StoreFactory.CreateStore( /* ... */ );
}
}
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 AsyncThunkCreator type to create an async thunk that can be dispatched to the store.
using Unity.AppUI.Core;
using Unity.AppUI.UI;
using Unity.AppUI.MVVM;
// Actions
public static class MyActions
{
static readonly ActionCreator IncrementAction = "mySlice/increment";
static readonly AsyncThunkCreator<int> IncrementAsyncThunk = new AsyncThunkCreator<int>("incrementAsyncThunk", async api =>
{
await Task.Delay(1000);
api.Dispatch(IncrementAction.Invoke()); // you can dispatch other actions inside the thunk.
return 0; // thunk always returns a value.
});
}
// Service
public class StoreService : IStoreService
{
public Store Store { get; }
public StoreService()
{
Store = StoreFactory.CreateStore(new []
{
StoreFactory.CreateSlice(
"mySlice",
new MyState(),
reducer =>
{
reducer.AddCase(MyActions.IncrementAction, (state, action) => state with { Count = state.Count + 1 });
}),
extraReducer =>
{
extraReducer.AddCase(MyActions.IncrementAsyncThunk.pending, (state, action) => state with { CanIncrement = false });
extraReducer.AddCase(MyActions.IncrementAsyncThunk.fulfilled, (state, action) => state with { CanIncrement = true });
}),
});
}
}
// 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 Increment()
{
var action = IncrementAsyncThunk.Invoke();
m_StoreService.Store.Dispatch(action);
}
}
// View
public class MyView : VisualElement
{
readonly Button m_Button;
public MyView(MyViewModel viewModel)
{
m_Button = new Button();
m_Button.clicked += () => viewModel.Increment();
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);
}
}