-
Notifications
You must be signed in to change notification settings - Fork 42
Defining Models
Just as Events and Effects, Model objects are opaque to the Mobius framework itself. They should have value semantics, but other than that they can be anything.
Since the Update function in Mobius represents state transitions in a state machine, it’s natural to see the model as representing the current state of that machine. When defining a model for the state machine, a spectrum of approaches is available to us, ranging from a strict finite-state-machine approach, to a more loosely defined “put everything in a bucket” approach.
If we want our model to accurately reflect the underlying finite-state machine it should be composed of mutually exclusive cases for each state.
Let's draft a small example of this using enumerations with associated values:
enum Model {
case waitingForData
case loaded(data: String)
case error(message: String)
}
As you see, the data
field only exists in the loaded
case, so you don’t have to unwrap an optional to access it, because you will only be in the loaded
case if data
is non-nil. This approach is perfect for small loops with few states, or when you want to be assured that all corner cases are covered.
However, there are some drawbacks to this approach, particularly when there are many states that start overlapping. For example, if there is an “offline” state, you might want to distinguish offline-but-no-data from offline-but-with-data ‒ this quickly leads to an explosion of the number of states and state transitions that must covered, and you might end up with plenty of boilerplate just to copy data from one state to another.
This approach is on the other end of the spectrum compared to the previous one. You use flags to keep track of whether data is loaded, etc., and store everything at the object’s “top level”.
Let’s use a struct for this example, and let’s include offline as an extra flag, too:
struct Model: Copyable {
var loaded: Bool
var error: Bool
var offline: Bool
var data: String?
var errorMessage: String?
}
Note: You might end up with a lot of properties that can be
nil
. There can also be invalid state combinations (in the case above, both loaded and error can be true at the same time), or cases with both data and an error message. This is of course an exaggerated case, but when you approach this end of the spectrum, you might get more special cases that must be handled carefully.
This kind of model tends to be easier to modify than the previous approach when requirements change and new states are required, and it is a lot easier to create new versions of model objects from old ones.
It is often advantageous to start with this kind of model, as it’s the most straightforward one to create and the easiest one to evolve as requirements change.
One good way to gain the conveniences of a single model, but still avoid invalid states, is to borrow some ideas from both previous approaches and go for a hybrid solution.
The first model provided a good way to deal with the regular states, and it was its offline scenario that messed things up. So instead of duplicating all states of the first model, let’s combine the first approach with the second one:
enum LoadingState {
case waitingForData
case loaded(data: String)
case error(message: String)
}
struct Model {
let offline: Bool
let loadingState: LoadingState
}
Now it’s possible to be both loaded and offline at the same time! We’ve combined two state machines by putting them next to each other ‒ one keeps track of data loading, and the other keeps track of whether you’re offline. Also, this approach scales up to multiple parallel state machines, or even state-machines-within-state-machines.
Note that this isn’t necessarily a perfect model: for example, maybe the waiting-for-data and offline states are incompatible. If it’s really important for you to deal with this state in the model, you’d have to go for something a bit more like the first approach, but if it’s just a single combination that is troublesome now, the hybrid solution is often a worthwhile trade-off.
The hybrid provides a more flexible model that is easier to modify when requirements change, and you’re still avoiding most edge cases (for example, in this version data is never nil
, and you can’t have both data and an error message).
Getting Started
Reference Guide
Patterns