Simple & correct UI logic with states
⚡️ Lightning talk intro to states
╔══════════════════════════╗
║ STATE ║
╟──────────────────────────╢
║ Reducer ◀───▶ Properties ║
║ ▲ │ ║
║ │ ▼ ║
║ Events Outputs ║
╚════╪═══════════════╪═════╝
· ─┼─ ─┼─
· │ ┌─────────┐ │
· ├─┼┼◀ TESTS ◀┼┼─┤
· │ └─────────┘ │
· ═╪═ ═╪═
╔════╪═══════════════╪═════╗
║ └─◀ Feedback ↻ ◀┘ ║
╟──────────────────────────╢
║ CONTROLLER ║
╚══════════════════════════╝
We use states in Trafi for a few reasons:
A state is the brains of a screen. It makes all the important decisions. It’s a simple type with three main parts:
swift
struct CoinState {
// 1. Privately stored data
private var isHeads: Bool = true
// 2. Enum of events
enum Event {
case flipToHeads
case flipToTails
}
// .. to create new state
static func reduce(state: CoinState, event: Event) -> CoinState {
switch event {
case .flipToHeads: return CoinState(isHeads: true)
case .flipToTails: return CoinState(isHeads: false)
}
}
// 3. Computed outputs to be handled by the controller
var coinSide: String {
return isHeads ? "Heads" : "Tails"
}
}
kotlin
data class CoinState(
// 1. Privately stored data
private val isHeads: Boolean = true
)
// 2. Enum of events
sealed class Event {
object FlipToHeads : Event()
object FlipToTails : Event()
}
// .. to create new state
fun CoinState.reduce(event: Event) = when(event) {
FlipToHeads -> copy(isHeads = true)
FlipToTails -> copy(isHeads = false)
}
// 3. Computed outputs to be handled by the controller
val CoinState.coinSide: String get() {
return isHeads ? "Heads" : "Tails"
}
There are many ways to write states. We can recommend following these steps:
Anything that just happened that the state should know about is an event. Events can be easily understood and listed by non-developers. Most events come from a few common sources:
tappedSearch
tappedResult
completedEditing
pulledToRefresh
loadedSearchResults
loadedMapData
becameReadyForRefresh
becameVisible
enteredBackground
wentOffline
changedCurrentLocation
As events are something that just happened we start their names with verbs in past simple tense.
swift
struct MyCommuteState {
enum Event {
case refetched(MyCommuteResponse)
case wentOffline
case loggedIn(Bool)
case activatedTab(index: Int)
case tappedFavorite(MyCommuteTrackStopFavorite)
case tappedFeedback(MyCommuteUseCase, MyCommuteFeedbackRating)
case completedFeedback(String)
}
}
kotlin
data class MyCommuteState(/**/)
sealed class Event {
data class Refetched(val response: MyCommuteResponse) : Event()
object WentOffline : Event()
data class LoggedIn(val isLoggedIn: Boolean) : Event()
data class ActivatedTab(val index: Int) : Event()
data class TappedFavorite(val favorite: MyCommuteTrackStopFavorite) : Event()
data class TappedFeedback(val feedback: Feedback) : Event()
data class CompletedFeedback(val message: String) : Event()
}
Outputs are the exposed getters of state. Controllers listen to state changes through outputs. Like events, outputs are simple enough to be understood and listed by non-developers. Most outputs can be categorized as:
isLoading: Bool
paymentOptions: [PaymentOption]
profileHeader: ProfileHeader
loadAutocompleteResults: String?
loadNearbyStops: LatLng?
syncFavorites: Void?
showStop: StopState?
showProfile: ProfileState?
dismiss: Void?
Any properties that are needed to compute the necessary outputs can be stored privately. We strive for this to be the minimal ground truth needed to represent any possible valid state.
swift
struct PhoneVerificationState {
private let phoneNumber: String
private var waitBeforeRetrySeconds: Int
}
kotlin
data class PhoneVerificationState(
private val phoneNumber: String,
private val waitBeforeRetrySeconds: Int
)
The reducer is a pure function that changes the state’s privately stored properties according to an event.
swift
struct CoinState {
private var isHeads: Bool = true
static func reduce(_ state: CoinState, event: Event) -> CoinState {
var result = state
switch event {
case .flipToHeads: result.isHeads = true
case .flipToTails: result.isHeads = false
}
return result
}
}
kotlin
data class CoinState(private val isHeads: Boolean) {
fun reduce(event: Event) = when(event) {
FlipToHeads -> copy(isHeads = true)
FlipToTails -> copy(isHeads = false)
}
}
We write specs (tests) in a BDD style. For Swift we use Quick
and Nible
, for Kotlin Spek
.
swift
class MyCommuteSpec: QuickSpec {
override func spec() {
var state: MyCommuteState!
beforeEach {
state = .initial(response: .dummy, now: .h(10))
}
context("When offline") {
it("Has no departues") {
expect(state)
.after(.wentOffline)
.toTurn { $0.activeFavorites.flatMap { $0.departures }.isEmpty }
}
it("Has no disruptions") {
expect(state)
.after(.wentOffline)
.toTurn { $0.activeFavorites.filter { $0.severity != .notAffected }.isEmpty }
}
}
}
}
kotlin
object NearbyStopsStateSpec : Spek({
describe("Stops near me") {
describe("when location is present") {
var state = NearbyStopsState(hasLocation = true)
beforeEach { state = NearbyStopsState(hasLocation = true) }
describe("at start") {
it("shows progress") { assertEquals(Ui.Progress, state.ui) }
it("tries to load stops") { assertTrue(state.loadStops) }
}
}
}
}
States become useful when their outputs are connected to UI, network requests, and other side effects.
Reactive streams compose nicely with the states pattern. We recommend using RxFeedback.swift / RxFeedback.kt to connect states to side effects in a reactive way.
swift
Driver.system(
initialState: input,
reduce: PhoneVerificationState.reduce,
feedback: uiBindings() + dataBindings() + [produceOutput()])
.drive()
.disposed(by: rx_disposeBag)
States are versatile and can be used with more traditional patterns, e.g. observer / listener patterns. On Android we use a simple state machine implementation which you can find in the Kotlin state tools.
kotlin
private val machine = StateMachine(PhoneVerificationState("+00000000000"))
machine.subscribeWithAutoDispose(viewLifecycleOwner) { boundState, newState ->
// do things with newState
}