After working extensively with ReactiveSwift (formerly known as ReactiveCocoa) and RxSwift for the last year and a half, hopefully I can shed some light on what works when it comes to getting down and dirty with real projects.
Some jargon to help get started, feel free to skip this:
Alright, let’s jump right in!
Oftentimes, when facing a problem to solve, the question will be: to create an Observable or Variable? The observable will be more pure-functional, which is laudable. And a variable introduces extra state, which is something we wish to minimise. Rather than having too much state, it is better to have a minimal ‘source of truth’ state, and pure-functional observables derived from said source of truth. This way, you cannot fall into the trap of updating, say, your user account in one class but forgetting to update it in another class.
To this end, I recommend taking a leaf out of Redux’s book, and follow the philosophy of having a single source of truth. For instance, I recommend one app singleton named State
, which in turn contains an instance of each category of state required, such as User
for login/logout state. Since, by nature, state is required to be stored here, I recommend using a Variable. Accessing would then look like so: State.shared.user.state
.
Example of state:
class State {
static let shared = State()
let user = UserState()
}
class UserState {
let isLoggedIn = Variable<Bool>(false)
let name = Variable<String?>(nil)
}
Alternatives proposed include having your individual state objects (such as UserState above) be individual singletons in their own right. However the approach in the example above has one advantage: it exposes a single convenient chokepoint at which you can substitute your mocks for testing: State.shared.
Many ViewControllers will do well to be backed by a view model. The view model is responsible for deriving a subset of the application’s state in a way that is simpler to apply to the appropriate screen. For instance, say you have a screen where the current user state is to be displayed in the navigation bar title: the view model is responsible for combining the State.shared.user.isLoggedIn
and the user.name
variables into one title
observable. I recommend that these view models be structs, and contain only observables that are derived from the state singletons’ variables.
Example of a view model:
struct UserDetailsViewModel {
let title: Observable<String> = Observable
.combineLatest(
State.shared.user.isLoggedIn,
State.shared.user.name)
.map({ (isLoggedIn, name) -> String in
if isLoggedIn {
return name
} else {
return "Logged out"
}
})
}
Wherever reasonable: Use Variable for a source of truth, and Observable/Signal for a derived truth. Where ‘source of truth’ means your state singleton, and ‘derived truth’ means your view models.
On the other hand: Sometimes it is simply unworkable to have only observables in your view models. Sometimes you just need the value ‘now’ rather than binding it and trusting that it will be there later. Sometimes it just isn’t practical to refactor your app to be Rx the whole way through. In these cases, my team has agreed on a pragmatic compromise: your view model will create pure-functional observables for all its fields, and then bind
that observable to a Variable, and expose the Variable. As part of this, you’ll want to change to using a class, so variable state and disposal is easier to reason about. For example:
class UserDetailsViewModel {
let title = Variable<String?>()
let disposeBag = DisposeBag()
init() {
Observable
.combineLatest(
State.shared.user.isLoggedIn,
State.shared.user.name)
.map({...})
.bind(to: title)
.disposed(by: disposeBag)
}
}
In your view controller, I recommend using bind
to connect your view models to your fields. For instance:
class EditUserViewController: UIViewController {
@IBOutlet var name: UITextField
let disposeBag = DisposeBag()
let viewModel: EditUserViewModel!
override func viewDidLoad() {
super.viewDidLoad()
viewModel.name
.bind(to: name.rx.text)
.disposed(by: disposeBag)
}
}
This will accomplish one direction of data flow: from the state model to the UI. For the reverse direction, e.g. when the user enters a new name and taps ‘save’, I recommend building a series of ‘State Services’ for persisting those back to the state singleton. Feel free to think of a better name than ‘state service’, by the way. For instance:
struct UserStateService {
static func save(name: String) {
State.shared.user.name.value = name
}
}
This way, your state services are the only layer responsible for applying business logic and directly manipulating your state, keeping such code away from view controllers, which we’d like to keep as simple as possible:
extension EditUserViewController {
func tapSave() {
UserStateService.save(name.text)
}
}
A grab-bag of ideas that I couldn’t figure where to fit above:
onNext
to update a field feels very imperative. Rather, bind
the view model to your fields. You want the code to read like ‘this is what I want it to do’ rather than ‘this is how I’d like it done’.State.shared.foo.x
in a map statement. Otherwise you run the risk that a change to X will not update your fields correctly.Best of luck!
Thanks for reading! And if you want to get in touch, I'd love to hear from you: chris.hulbert at gmail.
(Comp Sci, Hons - UTS)
Software Developer (Freelancer / Contractor) in Australia.
I have worked at places such as Google, Cochlear, Assembly Payments, News Corp, Fox Sports, NineMSN, FetchTV, Coles, Woolworths, Trust Bank, and Westpac, among others. If you're looking for help developing an iOS app, drop me a line!
Get in touch:
[email protected]
github.com/chrishulbert
linkedin