Model-View-Presenter, Form Validation, and a Unidirectional State Machine
As an exercise, I wrote a simple iOS scene transition from a login form (root view controller) to a success scene. I was experimenting with the Model-View-Presenter approach where view and presenter know each other, but communicate through protocols, so you program to an interface, not the implementation. I ended up using a simple state machine to represent the view’s state, and employed form validation that is represented in the types. I like how the result reads, so here’s a rundown of the exercise:
- Recap of MVP on iOS
- State machines
- Uni-directional flow of event, state updates, and view changes
Model-View-Presenter Revisited
The suggested approach was awkward at first, but not that bad once I got the hang of it. The view is dumb, so the view’s protocol exposes methods like show(title:)
, display(loginError:)
, and change(borderColor:ofFormElement:)
that allow the presenter to control what’s displayed on screen in fine detail.
Here’s an example from Fabrizio’s original post:
class ProductsPresenter {
// ...
func onStart() {
productsView.show(title: "Products")
productsView.showLoadingStatus()
productsRepository.get { [unowned self] retrievedProducts in
self.tryToShow(retrievedProducts: retrievedProducts)
self.productsView.hideLoadingStatus()
}
}
// ...
}
The presenter here controls the view’s contents in fine detail, and also gets data from a repository that holds model data. With the VIPER architecture pattern, you’d be splitting up the Presenter and Interactor at this point at least.
I found that my own presenters usually just pass a view model to the view and let the view handle its own configuration. While that keeps the presenter slim, it moves some of the display logic to the UIViewController
. Having that logic removed from the view controller was a nice change, even though I now want to refactor the presenter some more to minimize its impact. Shifting the logic might be a good idea for future refactorings.
LoginCredentials Model
With regard to the login form, I started with the Swift form validation code I wrote about last year.
I started with LoginCredentials
:
struct LoginCredentials {
let email: String
let password: String
}
Then I used the validation code from last year to wrap this as a Partial<LoginCredentials>
. Through Swift key-value-mutations, I could start with an empty Partial()
and reflect the user input from the email and passwort UITextField
s through partial.update(\.email, to: newValue)
.
I’ll show you details in a second, but let me add another requirement first.
Login Form State Transitions
The login form should show email and password fields, and a “Login” button that’s deactivated at first. When the user enters valid data into both fields, the button is enabled. Upon tapping, an activity indicator is shown. While the request is active, the button changes to “Cancel”. On success, the other scene is displayed; otherwise, an error is displayed, the activity indicator is hidden again, and the button offers a “Try Again”.
So these are the real elements of the scene:
- Email text field
- Password text field
- Button
- Activity indicator
- Multiline error label
You will also have noticed that the HTTP login request affects what the button does. At first, it triggers a login request; during the request, it offers a cancellation; after a failed request, it offers to retry. Upon success, this scene is reset and replaced.
So the whole scene can have multiple states. This is how I’d enumerate the states:
- Initial
- Email text field is blank
- Password text field is blank
- Button shows “Login” and is disabled
- Activity indicator and error are hidden
- Invalid form input
- Email text field is blank or not a valid email
- Password text field is blank
- Button shows “Login” and is disabled
- Activity indicator and error are hidden
- Valid form input
- Email text field contains a valid email
- Password text field contains something
- Button shows “Login” and is enabled
- Activity indicator and error are hidden
- Pending request
- Email text field is uneditable
- Password text field is uneditable
- Button shows “Cancel” and is enabled
- Activity indicator is visible
- Error label is hidden
- Failed request
- Email text field contains a valid email
- Password text field contains something
- Button shows “Try Again” and is enabled
- Activity indicator is hidden
- Error shows failure reason
- Successful request
- Email and password are cleared
- Button is reset and disabled again
- Activity indicator and error are hidden
- Transition to a new scene is triggered
Okay then, so the login scene can be represented by a state machine! The state, which I was adventurously naming “view model”, can look like this:
enum LoginViewModel {
case initial
case unvalidatedLogin(unvalidatedCredentials: Partial<LoginCredentials>)
case validatedLogin(loginCredentials: LoginCredentials)
case requestPending(request: LoginRequest)
case requestFailed(request: LoginRequest, message: String)
case requestSucceeded
}
The rules to determine if a Partial<LoginCredentials>
contains valid user input boil down to non-empty input plus validating an email for its email-ness. Here’s a very simple test for the “@” sign, which I don’t suggest you use, but should give you an idea:
let loginCredentialValidation = Partial<LoginCredentials>.Validation(
.required(\LoginCredentials.email),
.required(\LoginCredentials.password),
.valueValidation(keyPath: \LoginCredentials.email, { $0.contains("@") }))
With these rules, we can now transition from .unvalidatedLogin(unvalidatedCredentials:)
to .validatedLogin(loginCredentials:)
. The result of the validation itself is a LoginCredentials
object. Here’s a snippet from the state machine effect:
switch self.loginViewModel {
// ...
case .unvalidatedLogin(unvalidatedCredentials: let unvalidatedCredentials):
// Ensure the view is in the proper state
view.hideError()
view.hideActivityIndiator()
// Validate input ...
switch loginCredentialValidation.validate(unvalidatedCredentials) {
case .valid(let credentials):
// ... and transition to a new state
self.loginViewModel = .validatedLogin(loginCredentials: credentials)
case .invalid(let reasons):
// ... or display validation errors, e.g. by coloring text fields
self.displayValidationErrors(reasons: reasons)
}
// ...
}
Improving the State Machine to Separate State Transitions from Side Effects
I still refer to Andy Matuschak’s “A composable pattern for pure state machines with effects” because it teaches so many interesting things at once.
For example, a state machine there has a method called mutating func handle(event: Event) -> Command?
. It is mutating
because the event processing affects the state the machine is in. (Thanks to it being an enum
value type, this does not affect existing references.) It also produces an optional Command
, which is a side effect that is supposed to be consumed by another object.
Look at the transition from unvalidated to validated credentials above. In terms of a mutating state machine with clean separation of side effects, I want to show you a sketch of the transition in Andy’s much better approach.
Here’s the changed state machine: it still sports the same cases, but now also has Event
and Command
types. The example I will discuss pertains user input for the email text field to keep the example small enough to be grasp-able:
enum LoginViewModel {
// ...
case unvalidatedLogin(unvalidatedCredentials: Partial<LoginCredentials>)
case validatedLogin(loginCredentials: LoginCredentials)
// ... more cases here ...
enum Event {
case emailChanged(String)
case passwordChanged(String)
// ... more events for the other cases ...
}
enum Command {
case showInvalidInput(onEmail: Bool, onPassword: Bool)
case showValidInput
}
}
The event handler then takes the current state into account, plus the event, and implements state transitions plus optional side effects.
For example, when the input is invalid, and the user types into the email text field, the new text field’s text should be reflected in the state. Also, since this is supposed to update live, the updated state should trigger a validation pass to make it possible to transition from invalid to valid state. The validation needs to happen both when the input is known to be valid and invalid, because input can invalidate everything again, of course. In this example, typing an “@” will validate the email field, and removing it should invalidate it again, resulting in a proper state transition.
So I extract the validation into a helper method further down in the following example code:
extension LoginViewModel {
mutating func handle(event: Event) -> Command? {
switch (self, event) {
// Validate credentials live when typing
case (.unvalidatedLogin(var partial), .emailChanged(let email)):
// Reflect the changes
partial.update(\.email, to: email)
return self.validate(partial: partial)
// Invalidate credentials again when changed
case let (.validatedLogin(credentials), .emailChanged(email)):
var partial = Partial(from: credentials)
partial.update(\.email, to: email)
return self.validate(partial: partial)
// ...
}
// Same rules as above
static let validation = Partial<LoginCredentials>.Validation(
.required(\LoginCredentials.email),
.required(\LoginCredentials.password),
.valueValidation(keyPath: \LoginCredentials.email, { $0.contains("@") }))
/// Extracted validation code that is used multiple times in
/// `handle(event:)` for live input validation.
mutating private func validate(partial: Partial<LoginCredentials>) -> Command? {
switch LoginViewModel.validation.validate(partial) {
case .valid(let credentials):
self = .validatedLogin(loginCredentials: credentials)
return .showValidInput
case .invalid(let reasons):
self = .unvalidatedLogin(unvalidatedCredentials: partial)
let emailIsInvalid: Bool = // filer when keyPath == \.email in reasons
let passwordIsInvalid: Bool = // filer when keyPath == \.password in reasons
return .showInvalidInput(onEmail: emailIsInvalid,
onPassword: passwordIsInvalid)
}
}
}
With the side effect separated from the state change, a consuming service object can carry these out and update the view.
Implement the Presenter to Update the View and Carry Out Side Effects
Given a view that satisfies the following protocol to change the UI:
protocol LoginView {
func hideError()
func display(error: String)
func change(buttonIsEnabled: Bool)
func change(activityIndicatorIsVisible: Bool)
func indicateInvalidInput(onEmail: Bool, onPassword: Bool)
func indicateValidInput()
}
Then a presenter that owns the state and processes state transitions on email input can look as follows:
class LoginPresenter {
var viewModel: LoginViewModel
var view: LoginView
// Called by the view controller
func emailChanged(value: String) {
self.updateState(event: .emailChanged(email: value))
}
fileprivate updateState(event: LoginViewModel.Event) {
let oldState = viewModel
let command = viewModel.handle(event: event)
self.updateViewTransition(from: oldState, to: viewModel)
self.process(command: command)
}
fileprivate func updateView(from oldState: LoginViewModel, to newState: LoginViewModel) {
switch (oldState, newState) {
// Example: Button toggling when input turns valid/invalid
case (.unvalidatedLogin(_), .unvalidatedLogin(_)),
(.validatedLogin(_), .validatedLogin(_)):
break // Keep as-is
case (.unvalidatedLogin(_), .validatedLogin(_)):
self.view.change(buttonIsEnabled: true)
case (.validatedLogin(_), .unvalidatedLogin(_)):
self.view.change(buttonIsEnabled: false)
// Example: Activity indicator
case (_, .requestPending(_)):
self.view.change(activityIndicatorIsVisible: true)
case (.requestPending(_), _):
self.view.change(activityIndicatorIsVisible: false)
// Example: Error display
case (_, .requestFailed(_, message: let message)):
self.view.display(error: message)
case (.requestFailed(_), _):
self.view.hideError()
}
}
fileprivate func process(command: LoginViewModel.Command?) {
guard let command = command else { return }
switch command {
case let .showInvalidInput(onEmail: emailIsInvalid,
onPassword: passwordIsInvalid):
// E.g. turn text field borders red,
// show "X" for invalid input, ...
self.view.change(buttonIsEnabled: false)
self.view.indicateInvalidInput(
onEmail: emailIsInvalid,
onPassword: passwordIsInvalid)
case .showValidInput:
self.view.change(buttonIsEnabled: true)
self.view.indicateValidInput()
}
}
}
Of course email input is not all there is, so I extracted updateState(event:)
as a reusable private event handler. You can call it from a method like passwordChanged(value:)
, too, and also pass in events when the button is tapped.
Refactoring the Presenter
With a setup like this, the LoginPresenter
can be further refactored into multiple components. The view updates upon changed from previous to next state can become the presenter, while another object can carry out side-effect execution. Since the side-effects here affect the UI, it doesn’t make much sense in my book to split the presenter into multiple objects at that seam.
It does make sense to me to split LoginPresenter
and LoginEventHandler
in two, though, with one reacting to UI callbacks like emailChanged
, sending events to the state machine, and the other handling the view updates and side-effects as a pure projection of the state.
If you take this further, you end up with a uni-directional flow approach that is very similar to Flux/Redux and ReSwift: an event handler forwards events to an object that owns the state, called “Store”, and the store informs subscribers about the state updates. Here, we only have 1 subscriber, and the state changes are local. But the approach is very similar in principle once you begin to untangle the objects and clean up the control and information flow.
- A
LoginViewModelStore
is a service object that holds on to the state. Someone has to, and this concept is as good as any. Could also remain in the presenter, if you want. - The
LoginEventHandler
produces objects of typeLoginViewModel.Event
and forwards these to the store. - The
LoginPresenter
is informed by the store about state changes and commands.
A sketch in code:
enum LoginViewModel {
/// ... as above ...
}
class LoginViewModelStore {
let presenter: LoginPresenter
var state: LoginViewModel
func process(event: LoginViewModel.Event) {
let oldState = state
let command = state.handle(event: event)
self.presenter.updateViewTransition(from: oldState, to: viewModel)
self.presenter.process(command: command)
}
}
class LoginPresenter {
let view: LoginView
// Expose the change handlers directly that were private before
func updateViewTransition(from oldState: LoginViewModel, to newState: LoginViewModel) {
// ... as above ...
}
func process(command: LoginViewModel.Command?) {
// ... as above ...
}
}
class LoginEventHandler {
let store: LoginViewModelStore
func emailChanged(value: String) {
store.process(event: .emailChanged(email: value)
}
// ... other event handlers ...
}
Do you have any suggestions to improve the setup further? Do you actually like this less than earlier stages, or putting everything into view controllers? I’m curious to hear what y’all come up with in this situation.