{"action":"created","issue":{"url":"https://api.github.com/repos/kickstarter/ios-oss/issues/504","repository_url":"https://api.github.com/repos/kickstarter/ios-oss","labels_url":"https://api.github.com/repos/kickstarter/ios-oss/issues/504/labels{/name}","comments_url":"https://api.github.com/repos/kickstarter/ios-oss/issues/504/comments","events_url":"https://api.github.com/repos/kickstarter/ios-oss/issues/504/events","html_url":"https://github.com/kickstarter/ios-oss/pull/504","id":386582018,"node_id":"MDExOlB1bGxSZXF1ZXN0MjM1MjczMzgz","number":504,"title":"FunSwift2018: Reactive view models, simplified","user":{"login":"stephencelis","id":658,"node_id":"MDQ6VXNlcjY1OA==","avatar_url":"https://avatars0.githubusercontent.com/u/658?v=4","gravatar_id":"","url":"https://api.github.com/users/stephencelis","html_url":"https://github.com/stephencelis","followers_url":"https://api.github.com/users/stephencelis/followers","following_url":"https://api.github.com/users/stephencelis/following{/other_user}","gists_url":"https://api.github.com/users/stephencelis/gists{/gist_id}","starred_url":"https://api.github.com/users/stephencelis/starred{/owner}{/repo}","subscriptions_url":"https://api.github.com/users/stephencelis/subscriptions","organizations_url":"https://api.github.com/users/stephencelis/orgs","repos_url":"https://api.github.com/users/stephencelis/repos","events_url":"https://api.github.com/users/stephencelis/events{/privacy}","received_events_url":"https://api.github.com/users/stephencelis/received_events","type":"User","site_admin":false},"labels":[],"state":"open","locked":false,"assignee":null,"assignees":[],"milestone":null,"comments":1,"created_at":"2018-12-02T17:45:08Z","updated_at":"2018-12-13T01:57:37Z","closed_at":null,"author_association":"CONTRIBUTOR","pull_request":{"url":"https://api.github.com/repos/kickstarter/ios-oss/pulls/504","html_url":"https://github.com/kickstarter/ios-oss/pull/504","diff_url":"https://github.com/kickstarter/ios-oss/pull/504.diff","patch_url":"https://github.com/kickstarter/ios-oss/pull/504.patch"},"body":":wave: Hey squad!\r\n\r\nI gave a talk yesterday at Functional Swift Conference in which I live-refactored a Kickstarter view model and controller. @ifbarrera attended and suggested that I open a PR with the result. We've been tweaking the reactive view model approach a bit the past coupla years and have been wanting to share.\r\n\r\nHere's what the end result looks like:\r\n\r\nhttps://github.com/stephencelis/ios-oss/blob/40cfb4da8afd23ea4d4cea637fbab161c238839d/Library/ViewModels/SignupViewModel.swift\r\n\r\nMain benefits we've seen:\r\n\r\n- Addresses the big boilerplate problem. Instead of containing view model logic using a bunch of different constructs (including multiple protocols, a class/struct, an initializer, a bunch of properties, a bunch of input functions that delegate directly to private properties), we can use a single construct: a single function. Half of the implementation boilerplate goes away and things become a bit easier to digest as a result\r\n\r\n- Another benefit of this style is that we get unused warnings for outputs not used in the view controller (one of which I found during the refactor).\r\n\r\nNote that this PR adds ReactiveCocoa as a dependency for less verbose binding in the view controller (avoids the extra target-action boilerplate). This isn't necessary, so take it or leave it, but after using RxSwift+RxCocoa on a project, it's been pretty nice to avoid the extra work and it made my talk go by a bit quicker.\r\n\r\nNot in this PR: updated tests! Some (but definitely not all) of the boilerplate lost in the main view model reappears in the tests since you need to manually create mutable properties in the test case, but it seems like a nicer trade-off to hide this boilerplate away in tests. For example:\r\n\r\n``` diff\r\n internal final class SignupViewModelTests: TestCase {\r\n-  fileprivate let vm: SignupViewModelType = SignupViewModel()\r\n+  fileprivate let emailChangedProperty = MutableProperty(\"\")\r\n+  fileprivate let emailTextFieldReturnProperty = MutableProperty(())\r\n+  fileprivate let environmentLoggedInProperty = MutableProperty(())\r\n+  fileprivate let nameChangedProperty = MutableProperty(\"\")\r\n+  fileprivate let nameTextFieldReturnProperty = MutableProperty(())\r\n+  fileprivate let passwordChangedProperty = MutableProperty(\"\")\r\n+  fileprivate let passwordTextFieldReturnProperty = MutableProperty(())\r\n+  fileprivate let signupButtonPressedProperty = MutableProperty(())\r\n+  fileprivate let viewDidLoadProperty = MutableProperty(())\r\n+  fileprivate let weeklyNewsletterChangedProperty = MutableProperty<Bool>(nil)\r\n+\r\n   fileprivate let emailTextFieldBecomeFirstResponder = TestObserver<(), NoError>()\r\n   fileprivate let isSignupButtonEnabled = TestObserver<Bool, NoError>()\r\n   fileprivate let logIntoEnvironment = TestObserver<AccessTokenEnvelope, NoError>()\r\n   fileprivate let nameTextFieldBecomeFirstResponder = TestObserver<(), NoError>()\r\n   fileprivate let passwordTextFieldBecomeFirstResponder = TestObserver<(), NoError>()\r\n   fileprivate let postNotification = TestObserver<Notification.Name, NoError>()\r\n   fileprivate let setWeeklyNewsletterState = TestObserver<Bool, NoError>()\r\n   fileprivate let showError = TestObserver<String, NoError>()\r\n\r\n   override func setUp() {\r\n     super.setUp()\r\n\r\n+    let (\r\n+      emailTextFieldBecomeFirstResponder,\r\n+      isSignupButtonEnabled,\r\n+      logIntoEnvironment,\r\n+      passwordTextFieldBecomeFirstResponder,\r\n+      postNotification,\r\n+      nameTextFieldBecomeFirstResponder,\r\n+      setWeeklyNewsletterState,\r\n+      showError\r\n+    ) = signupViewModel(\r\n+      emailChanged: self.emailChangedProperty.signal,\r\n+      emailTextFieldReturn: self.emailTextFieldChangedProperty.signal,\r\n+      environmentLoggedIn: environmentLoggedInProperty.signal,\r\n+      nameChanged: self.nameTextFieldChangedProperty.signal,\r\n+      nameTextFieldReturn: self.nameTextFieldReturnProperty.signal,\r\n+      passwordChanged: self.passwordTextFieldChangedProperty.signal,\r\n+      passwordTextFieldReturn: self.passwordTextFieldReturnPropety.signal,\r\n+      signupButtonPressed: self.signupButtonPressedProperty.signal,\r\n+      viewDidLoad: viewDidLoadProperty.signal,\r\n+      weeklyNewsletterChanged: weeklyNewsletterChangedProperty.signal\r\n+    )\r\n-    self.vm.outputs.emailTextFieldBecomeFirstResponder\r\n+    emailTextFieldBecomeFirstResponder\r\n-      .observe(self.emailTextFieldBecomeFirstResponder.observer)\r\n-    self.vm.outputs.isSignupButtonEnabled.observe(self.isSignupButtonEnabled.observer)\r\n+    isSignupButtonEnabled.observe(self.isSignupButtonEnabled.observer)\r\n-    self.vm.outputs.logIntoEnvironment.observe(self.logIntoEnvironment.observer)\r\n+    logIntoEnvironment.observe(self.logIntoEnvironment.observer)\r\n-    self.vm.outputs.nameTextFieldBecomeFirstResponder.observe(self.nameTextFieldBecomeFirstResponder.observer)\r\n+    nameTextFieldBecomeFirstResponder.observe(self.nameTextFieldBecomeFirstResponder.observer)\r\n-    self.vm.outputs.passwordTextFieldBecomeFirstResponder\r\n+    passwordTextFieldBecomeFirstResponder\r\n-      .observe(self.passwordTextFieldBecomeFirstResponder.observer)\r\n-    self.vm.outputs.postNotification.map { $0.name }.observe(self.postNotification.observer)\r\n+    postNotification.map { $0.name }.observe(self.postNotification.observer)\r\n-    self.vm.outputs.setWeeklyNewsletterState.observe(self.setWeeklyNewsletterState.observer)\r\n+    setWeeklyNewsletterState.observe(self.setWeeklyNewsletterState.observer)\r\n-    self.vm.outputs.showError.observe(self.showError.observer)\r\n+    showError.observe(self.showError.observer)\r\n   }\r\n```\r\n\r\nIt's possible to get rid of some additional boilerplate by avoiding the destructuring (_e.g._, `let outputs = signupViewModel(…)`), but then the compiler won't warn you when you forget to use an output, so in this case the trade-off seems worth it.\r\n\r\nWe've really enjoyed this approach, so we hope you do too!"},"comment":{"url":"https://api.github.com/repos/kickstarter/ios-oss/issues/comments/446813922","html_url":"https://github.com/kickstarter/ios-oss/pull/504#issuecomment-446813922","issue_url":"https://api.github.com/repos/kickstarter/ios-oss/issues/504","id":446813922,"node_id":"MDEyOklzc3VlQ29tbWVudDQ0NjgxMzkyMg==","user":{"login":"stephencelis","id":658,"node_id":"MDQ6VXNlcjY1OA==","avatar_url":"https://avatars0.githubusercontent.com/u/658?v=4","gravatar_id":"","url":"https://api.github.com/users/stephencelis","html_url":"https://github.com/stephencelis","followers_url":"https://api.github.com/users/stephencelis/followers","following_url":"https://api.github.com/users/stephencelis/following{/other_user}","gists_url":"https://api.github.com/users/stephencelis/gists{/gist_id}","starred_url":"https://api.github.com/users/stephencelis/starred{/owner}{/repo}","subscriptions_url":"https://api.github.com/users/stephencelis/subscriptions","organizations_url":"https://api.github.com/users/stephencelis/orgs","repos_url":"https://api.github.com/users/stephencelis/repos","events_url":"https://api.github.com/users/stephencelis/events{/privacy}","received_events_url":"https://api.github.com/users/stephencelis/received_events","type":"User","site_admin":false},"created_at":"2018-12-13T01:57:37Z","updated_at":"2018-12-13T01:57:37Z","author_association":"CONTRIBUTOR","body":"The talk was posted here! https://www.youtube.com/watch?v=uTLG_LgjWGA"}}
{"actor":{"display_login":"stephencelis"}}