Skip to content

Defining Models

Like Events and Effects, Models are opaque to Mobius. The only requirement is that they are immutable.

Since the Update function represents state transitions in a state machine, where the model represents the current state of the machine. When defining the model of a state machine, there are many options for defining it between a finite-state machine and a less strict object containing a number of fields that encapsulate state.

All states use different classes

In the finite-state machine approach, one class per state means the machine can only be in one state at a time. This means each state would only hold data needed for that state.

sealed class Model {
    data object WaitingForData : Model()
    data class Loaded(val data: String) : Model()
    data class Error(val message: String) : Model()
}

At any given moment, the model can only be one of the three WaitingForData, Loaded, and Error classes. This approach is great for small loops with a few states, or to ensure all edge cases are handled.

This approach has a few drawbacks, particularly when there are a lot of states with overlapping data. For example, when maintaining an "offline" state, you may need to differentiate offline-without-data from offline-with-data. You'll find this results in a large number of individual states that require their own transitions to be defined.

All states use the same class

This approach is less strict in terms of a state machine, with all data being stored in top-level fields of the model.

data class Model(
    val loaded: Boolean,
    val error: Boolean,
    val offline: Boolean,
    val data: String?,
    val errorMessage: String?,
)

Warning

With this approach you're likely to end up with a lot of null fields. The could also be invalid combinations of fields for example if both loaded and error are true, or both the data and errorMessage are populated. Be careful when using this approach as you must properly consider various cases of different field states.

It is generally best to start with this approach when defining model as it is easy to evolve with new requirements.

Hybrid approach

By combining both previous approaches, we can get the best of both worlds: clear separation of data available in a given state, and reduced effort when evolving the model.

sealed class LoadingState {
    data object WaitingForData : LoadingState()
    data class Loaded(val data: String) : LoadingState()
    data class Error(val message: String) : LoadingState()
}

data class Model(
    val offline: Boolean,
    val loading: LoadingState,
)

In this example we can have both the Loaded data and be offline at the same time. This provides a scalable foundation for more complex loop behaviors while remaining easy to reason about.

Some useful tricks for model objects

Use data class for Models

Kotlin data classes provide useful utilities for Immutable objects. Perhaps the most important is the generated copy(...) method, allowing you to create a new instance with only specified fields changed.

data class Task(val description: String, val complete: Boolean)

val task1 = Task("hello", false)
val task2 = task1.copy(complete = true)

Use with-methods to manage copy complexity

For certain data classes, some usages of copy may become large and difficult to comprehend quickly. In these cases it is helpful to add specialized with functions to produce new model instances.

data class Model(
    val filter: Filter,
    val tasks: List<Task>,
) {

    fun withCompletedTask(completedTask: Task): Model {
        val newTasks = tasks.toMutableList()
        val taskIndex = newTasks.indexOf(completedTask)
        newTasks[taskIndex] = newTask[taskIndex].copy(completed = true)
        return copy(tasks = newTasks.toList())
    }
}