Concise Forms: Composable Architecture

Episode #132 • Jan 25, 2021 • Subscriber-Only

Building forms in the Composable Architecture seem to have the opposite strengths and weaknesses as vanilla SwiftUI. Simple forms are cumbersome due to boilerplate, but complex forms come naturally thanks to the strong opinion on dependencies and side effects.

Previous episode
Composable Architecture
Introduction
00:05
Forms in the Composable Architecture
01:05
Form validation and side effects
17:04
Simulating dependencies in Xcode previews
41:29
A basic test
46:55
A more advanced test
53:29
Next time: eliminating boilerplate
60:06

Unlock This Episode

Our Free plan includes 1 subscriber-only episode of your choice, plus weekly updates from our newsletter.

Introduction

So that’s the basics of building a moderately complex settings. We are leveraging the power of SwiftUI and the Form view to get a ton done for us basically for free. It’s super easy to get a form up on the screen, and easy for changes to that form to be instantly reflected in a backing model.

However, we did uncover a few complexities that arise when dealing with something a little bit more realistic, and not just a simple demo. For one thing, we saw that if we need to react to a change in the model, such as wanting to truncate the display name to be at most 16 characters, then we had to tap into the didSet of that field. However, it is very dangerous to make mutations in a didSet. As we saw it can lead to infinite loops, and it can be very difficult to understand all the code paths that can lead to an infinite loop. It’s possible for the didSet to call a method, which calls another method, which then mutates the property, and then bam… you’ve got an infinite loop on your hands.

Another complexity is that if you want to do something a little more complicated when the form changes, such as hook into notification permissions, then you are forced to leave the nice confines of ergonomic magic that SwiftUI provides for us. We needed to write a binding from scratch just so that we could call out to a view model method instead of mutating the model directly. Doing that wasn’t terrible, we just think it’s worth pointing out that working with more real world, complex examples you often can’t take advantage of SwiftUI’s nice features, and gotta get your hands dirty.

This episode is for subscribers only.

Subscribe to Point-Free

Access this episode, plus all past and future episodes when you become a subscriber.

See plans and pricing

Already a subscriber? Log in

Exercises

  1. Write a test for the unhappy path of denying push permission.

    Solution

    It looks a lot like the happy path test, but we can make a few changes:

    • Instead of introducing state for didRegisterForRemoteNotifications, we can leave that dependency as a fatalError instead.
    • When we receive an authorization response where granted is false, we need to assert that state flips the toggle back, as well.
    func testNotifications_UnhappyPath_Deny() {
      let store = TestStore(
        initialState: SettingsState(),
        reducer: settingsReducer,
        environment: SettingsEnvironment(
          mainQueue: DispatchQueue.immediateScheduler.eraseToAnyScheduler(),
          userNotifications: UserNotificationsClient(
            getNotificationSettings: {
              .init(value: .init(authorizationStatus: .notDetermined))
            },
            registerForRemoteNotifications: { fatalError() },
            requestAuthorization: { _ in
              .init(value: false)
            }
          )
        )
      )
    
      store.assert(
        .send(.sendNotificationsChanged(true)),
        .receive(.notificationSettingsResponse(.init(authorizationStatus: .notDetermined))) {
          $0.sendNotifications = true
        },
        .receive(.authorizationResponse(.success(false))) {
          $0.sendNotifications = false
        }
      )
    }
    
  2. Write a test for tapping send notifications when authorization stats starts in a denied state. Try to get as much coverage for this flow as possible.

    Solution

    Much like the previous test, but:

    • We can now fatal error in requestAuthorization, as well.
    • We must update state with the alert.
    • We should additionally send a .dismissAlert action to test niling out this state.
    func testNotifications_UnhappyPath_PreviouslyDenied() {
      let store = TestStore(
        initialState: SettingsState(),
        reducer: settingsReducer,
        environment: SettingsEnvironment(
          mainQueue: DispatchQueue.immediateScheduler.eraseToAnyScheduler(),
          userNotifications: UserNotificationsClient(
            getNotificationSettings: {
              .init(value: .init(authorizationStatus: .denied))
            },
            registerForRemoteNotifications: { fatalError() },
            requestAuthorization: { _ in fatalError() }
          )
        )
      )
    
      store.assert(
        .send(.sendNotificationsChanged(true)),
        .receive(.notificationSettingsResponse(.init(authorizationStatus: .denied))) {
          $0.alert = .init(title: "You need to enable permissions from iOS settings")
        },
        .send(.dismissAlert) {
          $0.alert = nil
        }
      )
    }
    

References

Collection: Dependencies

Brandon Williams & Stephen Celis

Dependencies can wreak havoc on a codebase. They can increase compile times, are difficult to test, and put strain on build tools. Did you know that Core Location, Core Motion, Store Kit, and many other frameworks do not work in Xcode previews? Any feature touching these frameworks will not benefit from the awesome feedback cycle that previews afford us. This collection clearly defines dependencies and shows how to take back control in order to unleash some amazing benefits.

Downloads