⚡️ Lightning talk intro to states
- 🤔 Why should I use states?
- 🖼 What is a state?
- 👨🎨 How do I write states?
- 👨🔬 How do I write specs?
- 👨🔧 How do I use states?
╔══════════════════════════╗ ║ STATE ║ ╟──────────────────────────╢ ║ Reducer ◀───▶ Properties ║ ║ ▲ │ ║ ║ │ ▼ ║ ║ EventsOutputs ║ ╚════╪═══════════════╪═════╝ · ─┼─ ─┼─ · │ ┌─────────┐ │ · ├─┼┼◀ TESTS ◀┼┼─┤ · │ └─────────┘ │ · ═╪═ ═╪═ ╔════╪═══════════════╪═════╗ ║ └─◀ Feedback ↻ ◀┘ ║ ╟──────────────────────────╢ ║ CONTROLLER ║ ╚══════════════════════════╝
We use states in Trafi for a few reasons:
- States break down big problems to small pieces
- States keep our code pure and easily testable
- States help us share solutions between platforms & languages
- States make debugging easier with a single pure function
- It's an unusual development flow
- Overhead for very simple cases
- Takes time and practice to integrate into existing code
A state is the brains of a screen. It makes all the important decisions. It's a simple type with three main parts:
- Privately stored data
- Enum of events to create new state
- Computed outputs to be handled by the controller
🔎 See a simple example
structCoinState{
// 1. Privately stored data
privatevarisHeads:Bool=true
// 2. Enum of events
enumEvent{case flipToHeads
case flipToTails
}
// .. to create new state
staticfunc reduce(state:CoinState, event:Event)->CoinState{switch event {case.flipToHeads:returnCoinState(isHeads:true)case.flipToTails:returnCoinState(isHeads:false)}}
// 3. Computed outputs to be handled by the controller
varcoinSide:String{return isHeads ?"Heads":"Tails"}}data classCoinState(
// 1. Privately stored dataprivatevalisHeads:Boolean = true
)
// 2. Enum of eventssealedclassEvent {
object FlipToHeads : Event()
object FlipToTails : Event()
}
// .. to create new statefun CoinState.reduce(event:Event) =when(event) {
FlipToHeads-> copy(isHeads =true)
FlipToTails-> copy(isHeads =false)
}
// 3. Computed outputs to be handled by the controllervalCoinState.coinSide:String get() {
return isHeads ?"Heads":"Tails"
}
There are many ways to write states. We can recommend following these steps:
- Draft a platform-independent interface:
- List events that could happen
- List outputs to display UI, load data and navigate
- Implement the internals:
- ❌ Write a failing test that sends an event and asserts an output
- ✅ Add code to state till test passes
- 🛠 Refactor code so it's nice, but all tests still pass
- 🔁 Continue writing tests for all events and outputs
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:
- User interactions
tappedSearchtappedResultcompletedEditingpulledToRefresh
- Networking
loadedSearchResultsloadedMapData
- Screen lifecycle
becameReadyForRefreshbecameVisibleenteredBackground
- Device
wentOfflinechangedCurrentLocation
As events are something that just happened we start their names with verbs in past simple tense.
🔎 See an example
structMyCommuteState{enumEvent{case refetched(MyCommuteResponse)case wentOffline
case loggedIn(Bool)case activatedTab(index:Int)case tappedFavorite(MyCommuteTrackStopFavorite)case tappedFeedback(MyCommuteUseCase,MyCommuteFeedbackRating)case completedFeedback(String)}}data classMyCommuteState(/**/)
sealedclassEvent {
data classRefetched(valresponse:MyCommuteResponse) : Event()
object WentOffline : Event()
data classLoggedIn(valisLoggedIn:Boolean) : Event()
data classActivatedTab(valindex:Int) : Event()
data classTappedFavorite(valfavorite:MyCommuteTrackStopFavorite) : Event()
data classTappedFeedback(valfeedback:Feedback) : Event()
data classCompletedFeedback(valmessage: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:
- UI. These are usually non-optional outputs that specific UI elements are bound to, e.g:
isLoading: BoolpaymentOptions: [PaymentOption]profileHeader: ProfileHeader
- Data. These are usually optional outputs that controllers react to. Their names indicate how to react and their types give associated information if needed, e.g:
loadAutocompleteResults: String?loadNearbyStops: LatLng?syncFavorites: Void?
- Navigation. These are always optional outputs that are just proxies for navigation, e.g.:
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.
🔎 See an example
structPhoneVerificationState{privateletphoneNumber:StringprivatevarwaitBeforeRetrySeconds:Int}data classPhoneVerificationState(
privatevalphoneNumber:String,
privatevalwaitBeforeRetrySeconds:Int
)The reducer is a pure function that changes the state's privately stored properties according to an event.
🔎 See an example
structCoinState{privatevarisHeads:Bool=truestaticfunc reduce(_ state:CoinState, event:Event)->CoinState{varresult= state
switch event {case.flipToHeads: result.isHeads =truecase.flipToTails: result.isHeads =false}return result
}}data classCoinState(privatevalisHeads:Boolean) {
funreduce(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.
🔎 See an example
classMyCommuteSpec:QuickSpec{overridefunc spec(){varstate: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 }}}}}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.
🔎 See an example
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.