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:

  1. 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 application TodoAppState and update the TodoReducer to handle this new screen's actions. This will hurt scalability in the long run.
  2. 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 with TodoSettings then this reducer acts on the TodoSettings state from the store.
  • TodoReducer reduce function deals with TodoList then this reducer acts on the TodoList 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 own reducer, 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