Applications with multiple states
Each application has one Store that contains your application full state. In some situations, your application might be structured in a way that two or more screens (controllers or activities) are separate, disjoint or decoupled.
For example, in our todo app with settings example, we have the following screens setup:
- A main todo screen, that contains a list of todos,
- A settings screen that defines some application wide settings. Such as the background color and text color for todo items.
The states for each of these screens look like.
// Todo Screen
struct TodoList {
var Items: [TodoItem]
}
// Todo Screen
data class TodoList(val item: List<TodoItem>)
// Todo Screen
class TodoList {
List<TodoItem> items;
// constructor + getter
}
// Settings Screen
struct TodoSettings {
var backgroundColor: UIColor
var textColor: UIColor
}
// Settings Screen
data class TodoSettings(
val backgroundColor: Int,
val textColor: Int)
// Settings Screen
class TodoSettings {
int backgroundColor;
int textColor;
// constructor + getter
}
In this example, there are two solutions; we can join both settings in one combined state or we can use multiple states.
Using a single combined state
We can create a state that combines the two application screens sub states like the following:
struct TodoAppState {
var settings: TodoSettings
var todos: TodoList
}
data class TodoAppState(
val settings: TodoSettings,
val todos: TodoList
)
class TodoStateApp {
TodoSettings settings;
TodoList todos;
// constructor + getter
}
Having a single struct means we have one single reducer that reduces actions responsible for both the todo and settings screens.
// Reduces both todos and settings actions and state
struct TodoReducer: Reducer {
var initialState = TodoAppState(...)
func reduce(action: Action, state: TodoAppState) -> TodoAppState? {
// Handle todos and settings actions
return nil
}
}
// Reduces both todos and settings actions and state
class TodoReducer : Reducer<TodoAppState>() {
override fun getInitialState(): TodoAppState = TodoAppState()
override fun reduce(state: TodoAppState, action: Action<*>): TodoAppState? {
// Handle todos and settings actions
return null
}
}
// Reduces both todos and settings actions and state
class TodoReducer extends Reducer<TodoStateApp> {
@NonNull
@Override
public TodoStateApp getInitialState() {
return new TodoStateApp();
}
@Nullable
@Override
public TodoStateApp reduce(@NonNull TodoStateApp state, @NonNull Action<?> action) {
// Handle todos and settings actions
return null;
}
}
Doing the above works fine for small to medium apps, however, in larger applications, it has the following drawbacks:
- As your application grows, your single application state grows and your reducer grows with it.
If you add a new screen to your app, for example, you have to append the new screen state in the applicationTodoAppState
and update theTodoReducer
to handle this new screen's actions. This will hurt scalability in the long run. - Listeners added to the store will be notified when any sub state changes. (This can be solved also by adding a listener with a filter)
In the next section, we address both the issues.
Using multiple states
Instead of defining a struct that contains both our TodoList
and the TodoSettings
sub states, we design our Store state so that it contains two sub states:
TodoList
state which represents the state of the todo main app screen.TodoSettings
struct which represents the state of the todo app settings screen.
Each of these states has its own reducer
:
// Reduces todos actions and state only
struct TodoReducer: Reducer {
var initialState = TodoList(todos: [])
func reduce(action: Action, state: TodoList) -> TodoList? {
// Handle todos actions only
return nil
}
}
// Reduces todos actions and state only
class TodoReducer : Reducer<TodoList>() {
override fun getInitialState(): TodoList = TodoList(item = listOf())
override fun reduce(state: TodoList, action: Action<*>): TodoList? {
// Handle todos actions only
return null
}
}
// Reduces todos actions and state only
class TodoReducer extends Reducer<TodoList> {
@NonNull
@Override
public TodoList getInitialState() {
return new TodoList();
}
@Nullable
@Override
public TodoList reduce(@NonNull TodoList state, @NonNull Action<?> action) {
// Handle todos actions only
return null;
}
}
// Reduces settings actions and state only
struct SettingsReducer: Reducer {
var initialState = TodoSettings(backgroundColor: .red, textColor: .white)
func reduce(action: Action, state: TodoSettings) -> TodoSettings? {
// Handle settings actions only
return nil
}
}
// Reduces settings actions and state only
class SettingsReducer : Reducer<TodoSettings>() {
override fun getInitialState(): TodoSettings = TodoSettings(backgroundColor = Color.RED, textColor = Color.WHITE)
override fun reduce(state: TodoSettings, action: Action<*>): TodoSettings? {
// Handle settings actions only
return null
}
}
// Reduces settings actions and state only
class SettingsReducer extends Reducer<TodoSettings> {
@NonNull
@Override
public TodoSettings getInitialState() {
return new TodoSettings(Color.RED, Color.WHITE);
}
@Nullable
@Override
public TodoSettings reduce(@NonNull TodoSettings state, @NonNull Action<?> action) {
// Handle settings actions only
return null;
}
}
Notice the following:
- Each reducer defines the initial state for the sub state it will reduce only.
- Each reducer handles only the set of actions that are related to that reducer responsibility.
- Each reducer reduces only the sub state related to that reducer responsibility.
- (Optional) In rare advanced usages the reducer can define a
stateKey
. This state key specifies the key that this reducer will reduce. More about it in specifying a StateKey section.
When we create the Store we pass both these reducers by adding the reducers with +
operator. This is also called passing a combined reducer
.
let store = Suas.createStore(reducer: TodoReducer() + SettingsReducer())
val store = Suas.createStore(TodoReducer(), SettingsReducer()).build()
Store store = Suas.createStore(new TodoReducer(), new SettingsReducer()).build();
The state for this Store
configuration is a dictionary
(HashMap) that has the state type name (class/struct type) as the key and the sub state as the value. For our todo app it looks like:
{
"TodoSettings": TodoSettings(),
"TodoList": TodoList()
}
Each reducer acts on a single sub state only:
SettingsReducer
reduce
function deals withTodoSettings
then this reducer acts on theTodoSettings
state from the store.TodoReducer
reduce
function deals withTodoList
then this reducer acts on theTodoList
state from the store.
When can then add listeners
to the store with different flavors:
// A listener that is informed when settings or todos keys in the state changes
// I.e This listener listens to any change in the full state
store.addListener { allApplicationStore in ... }
// A listener that is informed only when "TodoSettings" key of the state changes
// I.e This listener listens to the settings part of our application state
store.addListener(forStateType: TodoSettings.self) { onlySettings in ... }
// A listener that is informed only when "TodoList" key of the state changes
// I.e This listener listens to the todo part of our application state
store.addListener(forStateType: TodoList.self) { onlyTodos in ... }
// A listener that is informed when settings or todos keys in the state changes
// I.e This listener listens to any change in the full state
store.addListener { state -> ... }
// A listener that is informed only when "TodoSettings" key of the state changes
// I.e This listener listens to the settings part of our application state
store.addListener(TodoSettings::class.java) { onlySettings -> }
// A listener that is informed only when "TodoList" key of the state changes
// I.e This listener listens to the todo part of our application state
store.addListener(TodoList::class.java) { onlyTodo -> }
// A listener that is informed when settings or todos keys in the state changes
// I.e This listener listens to any change in the full state
store.addListener((state) -> { });
// A listener that is informed only when "TodoSettings" key of the state changes
// I.e This listener listens to the settings part of our application state
store.addListener(TodoSettings.class, (onlySettings) -> { });
// A listener that is informed only when "TodoList" key of the state changes
// I.e This listener listens to the todo part of our application state
store.addListener(TodoList.class, (onlyTodo) -> { });
When adding a listener, we can customize the notification process by passing a stateConverter
and a filter block
. These customizations are covered in details in using the StateConverter and adding a listener with a filter.
iOS: Using Weak self in Listeners
If you are using self inside the listener notification, make sure to capture self weakly.
let subscription = store.addListener(forStateType: TodoList.self) { [weak self] state in // Setting the state in the view self?.state = state }
This is important to prevent strong memory cycles that lead to leaking.
Specifying a StateKey
In general cases, we do not need to specify the state key when creating Reducers
. When we don't specify a state key, the Reducer's
state type name (i.e. the class/struct name) is used.
When adding Listeners
we can also ignore the state key. By not specifying the state key Suas
will associate Listeners
to a state key equal to the type name of the passed type when adding a listener.
In our example above. If the TodoReducer
and SettingsReducer
did not specify the stateKey
the Store
state will look like:
{
"TodoSettings": TodoSettings(),
"TodoList": TodoList()
}
Notice how the name of the state type (class/struct) is used as the key in the Store
state.
When we add listeners to this store, we don't have to specify the state key. Let's have a look at an example.
store.addListener(forStateType: TodoList.self) { onlyTodo in
// Only informed when "TodoList" key of the state changes
}
store.addListener(TodoList::class.java) { onlyTodo ->
// Only informed when "TodoList" key of the state changes
}
store.addListener(TodoLost.class, (onlyTodo) -> {
// Only informed when "TodoList" key of the state changes
});
The listeners added above will be notified if the TodoList
key in the state changes. Similarly, we can add listeners that get notified when the SettingsReducer
changes:
store.addListener(forStateType: TodoSettings.self) { onlySettings in
// Only informed when "TodoSettings" key of the state changes
}
store.addListener(TodoSettings::class.java) { onlySettings ->
// Only informed when "TodoSettings" key of the state changes
}
store.addListener(TodoSettings.class, (onlySettings) -> {
// Only informed when "TodoSettings" key of the state changes
});
However, we can customize the stateKey
for the reducer when defining them. Let's give our state keys different values:
// Reduces todos actions and state only
struct TodoReducer: Reducer {
var initialState = TodoList(todos: [])
var stateKey = "todos"
func reduce(action: Action, state: TodoList) -> TodoList? {
// Handle todos actions only
return nil
}
}
// Reduces settings actions and state only
struct SettingsReducer: Reducer {
var initialState = TodoSettings(backgroundColor: .red, textColor: .white)
var stateKey = "settings"
func reduce(action: Action, state: TodoSettings) -> TodoSettings? {
// Handle settings actions only
return nil
}
}
// Reduces todos actions and state only
class TodoReducer : Reducer<TodoList>() {
override fun getInitialState(): TodoList = TodoList(item = listOf())
override fun getStateKey(): String = "todos"
override fun reduce(state: TodoList, action: Action<*>): TodoList? {
// Handle todos actions only
return null
}
}
// Reduces settings actions and state only
class SettingsReducer : Reducer<TodoSettings>() {
override fun getInitialState(): TodoSettings = TodoSettings(backgroundColor = Color.RED, textColor = Color.WHITE)
override fun getStateKey(): String = "settings"
override fun reduce(state: TodoSettings, action: Action<*>): TodoSettings? {
// Handle settings actions only
return null
}
}
// Reduces todos actions and state only
class TodoReducer extends Reducer<TodoList> {
@NonNull
@Override
public TodoList getInitialState() {
return new TodoList();
}
@NonNull
@Override
public String getStateKey() {
return "todos";
}
@Nullable
@Override
public TodoList reduce(@NonNull TodoList state, @NonNull Action<?> action) {
// Handle todos actions only
return null;
}
}
// Reduces settings actions and state only
class SettingsReducer extends Reducer<TodoSettings> {
@NonNull
@Override
public TodoSettings getInitialState() {
return new TodoSettings(Color.RED, Color.WHITE);
}
@NonNull
@Override
public String getStateKey() {
return "settings";
}
@Nullable
@Override
public TodoSettings reduce(@NonNull TodoSettings state, @NonNull Action<?> action) {
// Handle settings actions only
return null;
}
}
We passed todos
for the state key of TodoReducer
and settings
for the SettingsReducer
. When passing these the store, we get a state dictionary that looks like this:
{
"todos": TodoList(),
"settings": TodoSettings()
}
Now when adding the listener we need to match the stateKey
of the reducer. Let's see how to add listeners to the store:
// A listener that is informed only when "settings" key of the state changes
// I.e This listener listens to the settings part of our application state
store.addListener(forStateType: TodoSettings.self, stateKey: "settings") { onlySettings in ... }
// A listener that is informed only when "TodoList" key of the state changes
// I.e This listener listens to the todo part of our application state
store.addListener(forStateType: TodoList.self, stateKey: "todos") { onlyTodos in ... }
// A listener that is informed only when "settings" key of the state changes
// I.e This listener listens to the settings part of our application state
store.addListener("settings", TodoSettings::class.java) { onlySettings ->
}
// A listener that is informed only when "TodoList" key of the state changes
// I.e This listener listens to the todo part of our application state
store.addListener("todos", TodoList::class.java) { onlySettings ->
}
// A listener that is informed only when "settings" key of the state changes
// I.e This listener listens to the settings part of our application state
store.addListener("settings", TodoSettings.class, (onlySettings) -> {
});
// A listener that is informed only when "TodoList" key of the state changes
// I.e This listener listens to the todo part of our application state
store.addListener("todos", TodoList.class, (onlyTodo) -> {
});
The first listener we added listens to the settings
settings and expect the state in that key to be of TodoSettings
type. While the second listener listens to todos
key and expects TodoList
type.
When would you pass a different state key?
In 99% of the cases, you don't need to specify the stateKey
as the name of the state type will be used.
Some reasons to set the state key?
- You want to use a better name for your state that will eventually appear in your logs when using
LoggerMiddleware
- You want to have the same state keys for both your Android and iOS app. In that situation, you can customize the state key in your reducers for Android and iOS to be the same name.
Final Words
Having different reducers for different screens/view-hierarchies is helpful in many ways:
- You can focus on defining your sub states without the need to create big container states.
- Listeners can listen to specific keys. Updates in some sub state won't trigger all listeners.
- It helps with scalability; when adding a new screen we don't have to append to the app state struct/class. Instead, we only define a new reducer/state pair for that screen.
Additionally, each state gets its ownreducer
, which prevents a single reducer from becoming too big.
What's Next
Check the todo sample application with settings example which implements the above solution.
Related Topics
Using the StateSelector
Adding a listener with a filter
Using middlewares
Async Actions
Updated about 6 years ago