EP 132 · Concise Forms · Jan 25, 2021 ·Members

Video #132: Concise Forms: Composable Architecture

smart_display

Loading stream…

Video #132: Concise Forms: Composable Architecture

Episode: Video #132 Date: Jan 25, 2021 Access: Members Only 🔒 URL: https://www.pointfree.co/episodes/ep132-concise-forms-composable-architecture

Episode thumbnail

Description

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.

Video

Cloudflare Stream video ID: 2fb4d0d6346d3537527bdc3247570c84 Local file: video_132_concise-forms-composable-architecture.mp4 *(download with --video 132)*

References

Transcript

0:05

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.

0:27

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.

34:06

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. Forms in the Composable Architecture

1:05

So, we’ve learned a lot about what SwiftUI has to say about forms, but what about the Composable Architecture?

1:45

As most of our viewers probably already know, the Composable Architecture is a library for building applications with a focus on modularity, side effects and testing, and it’s something we built from scratch over the course of many episodes and then finally open sourced more than 6 months ago.

2:02

We would love to say that the Composable Architecture makes dealing with complex forms a breeze, but unfortunately that isn’t quite right. While the Composable Architecture can improve on some of the pain points that we saw with the vanilla SwiftUI code, it sadly is more tedious when it comes to the things that SwiftUI excels at.

2:21

However, it doesn’t have to be that way! Let’s start by naively converting this feature over to the Composable Architecture to see where the tedium comes from, and then see how we can fix it.

2:34

Let’s start by creating a new file called TCAFormView.swift and let’s paste the view we have already created, and rename it to TCAFormView

2:55

Let’s remove the view model from the view since we will no longer be implementing it’s logic in this way: struct TCAFormView: View { // @ObservedObject var viewModel: SettingsViewModel … }

3:03

That is going to break a lot of things, but let’s just do the bare minimum to get things compiling again. For example, all the bindings can be flipped back to constant bindings: TextField( "Display name", text: .constant("") // self.$viewModel.displayName ) Toggle( "Protect my posts", isOn: .constant(false) // self.$viewModel.protectMyPosts ) … Toggle( "Send notifications", isOn: .constant(false) // Binding.init( // get: { self.viewModel.sendNotifications }, // set: { isOn, transaction in // self.viewModel.tryTogglingSendNotifications(isOn: isOn) // } // ) // self.$viewModel.sendNotifications ) … Picker( "Activity digest", selection: .constant(Digest.off) // self.$viewModel.digest ) { ForEach(Digest.allCases, id: \.self) { digest in Text(digest.rawValue) } } … // .alert(item: self.$viewModel.alert) { alert in .alert(item: .constant(AlertState?.none)) { alert in Alert(title: Text(alert.title)) } And all other references to the view model can be stubbed out for now: if false { // self.viewModel.sendNotifications { … } … Button("Reset") { withAnimation { // self.viewModel.reset() } }

3:43

We now want to reimplement this feature with the Composable Architecture rather than using a SwiftUI observable object. So let’s add a dependency to the project so that we have access to the Composable Architecture library.

4:16

And now we can import it to get access to all of its functionality: import ComposableArchitecture

4:24

Implementing a feature with the Composable Architecture consists of a few steps. First we model the domain of the feature. This means specifying the state that the feature needs to do its job, specifying all of the actions that the user can perform on the screen, and the environment of dependencies the feature needs to do its job.

4:42

The state is simple enough, it’s basically exactly what we had in our view model, except now we get to model it as a struct: struct SettingsState { var alert: AlertState? = nil var digest = Digest.daily var displayName = "" var protectMyPosts = false var sendNotifications = false }

4:53

The actions are the tedious part unfortunately. We need to specify an action for each thing the user can do in the interface, which means an enum with a case for the text field, a case for each toggle, a case for tapping the reset button. There is also a subtle action to think about, which is the action of the user dismissing the alert. The vanilla SwiftUI application handled this for us implicitly because the alert could just write straight to the binding to nil out the alert state, but in the Composable Architecture we will have to clean up that state manually.

5:24

So, our action enum can look like this: enum SettingsAction { case digestChanged(Digest) case dismissAlert case displayNameChanged(String) case protectMyPostsChanged(Bool) case resetButtonTapped case sendNotificationsChanged(Bool) }

6:07

The environment holds the dependencies that are needed to implement this feature, such as API clients, analytics clients and more. We will be adding things to our environment soon enough, but for now let’s create an empty stub: struct SettingsEnvironment {}

6:28

The next step, after having defined our domain, is to define a reducer that glues the domain together to implement the feature’s logic. This is the function that takes the current state of the application, along with an action from the user and an environment that can run side effects, and produces the next state of the application.

6:50

For now let’s just get a stub of the functionality in place that does nothing and returns no side effect: let settingsReducer = Reducer< SettingsState, SettingsAction, SettingsEnvironment > { state, action, environment in return .none }

7:01

Next we introduce a store to our view, which is the runtime that can actually drive our feature. It’s the thing you can read state from so that you can update the UI, and the thing you send actions to so that the reducer can process the action to evolve the state.

7:16

We can do this by introducing a let variable to the view: struct TCAFormView: View { let store: Store<SettingsState, SettingsAction> … }

7:26

To get access to the state and to send actions to the store we have to wrap our view in a special view called a WithViewStore : var body: some View { WithViewStore(self.store) { viewStore in … } }

7:48

But in order to do that we have to make sure our SettingsState struct is equatable so that this view store can prevent needless rendering of the view: struct AlertState: Equatable, Identifiable { … } … struct SettingsState: Equatable { … }

8:13

With the view store in place we can now read state from it and send it actions. As a simple example, we previously looked to the view model to figure out if notifications were turned on so that we could show or hide the digest picker, but now that can be done with the view store: // if self.viewModel.sendNotifications { if viewStore.sendNotifications { … }

8:38

And previously we invoked a method on the view model when the reset button was tapped, but now we can just send an action to the view store: Button("Reset") { withAnimation { // self.viewModel.reset() viewStore.send(.resetButtonTapped) } }

8:51

A more complicated example of dealing with state and actions is bindings, because they simultaneously deal with reading state and changing state. A key tenet of the Composable Architecture is that you never directly mutate the state of your application. The only time state is mutated is when an action is sent to the store. This comes with a ton of benefits, such as making it super easy to understand how data flows through your application and making your application instantly testable.

9:17

However, this doesn’t play nicely with bindings, which typically want to make mutations to the state directly. As we saw previously, the only way to circumvent that is to implement a binding from scratch so that in the set endpoint we do something else, such as invoke a method on the view model or send an action to the store.

9:35

Luckily the Composable Architecture gives us a helper method to automatically derive these bindings for us. We can use it to specify what property from the view store we want to use for the binding, and what action should be sent when a new value is set on the binding.

9:52

For the display name text field, we can derive a binding from the view store: TextField( "Display name", text: viewStore.binding( get: <#(State) -> LocalState#>, send: <#(LocalState) -> Action#> ) // .constant("") // self.$viewModel.displayName )

10:06

For the get closure we can simply pluck out the display name field: get: { settingsState in settingsState.displayName },

10:24

And the send closure is handed a brand new string that we can package up in the displayNameChanged action. send: { newDisplayName in SettingsAction.displayNameChanged(newDisplayName) }

10:44

We can even simplify things a bit using key path expression syntax for the get field, and passing along SettingsAction.displayNameChanged to send , since enum cases are functions, and this case takes a string and returns a SettingsAction , which is exactly the function signature we need here. text: viewStore.binding( get: \.displayName, send: SettingsAction.displayNameChanged )

11:16

Similarly the binding for the post protection toggle can be done like so: Toggle( "Protect my posts", isOn: viewStore.binding( get: \.protectMyPosts, send: SettingsAction.protectMyPostsChanged ) )

11:31

And the notifications toggle like so: Toggle( "Send notifications", isOn: viewStore.binding( get: \.sendNotifications, send: SettingsAction.sendNotificationsChanged ) )

11:42

The picker binding can be done like so: Picker( "Activity digest", selection: viewStore.binding( get: \.digest, send: SettingsAction.digestChanged ) )

11:49

And finally the alert binding like so: .alert( item: viewStore.binding( get: \.alert, send: SettingsAction.dismissAlert ) ) { alert in Alert(title: Text(alert.title)) }

12:07

That does it for the view. It is now fully driven off the logic of the store. To see this, let’s configure a preview.

12:16

A preview needs the ability to construct a TCAFormView , and to do that we need to pass it a store, which is the runtime that powers the Composable Architecture: it holds onto your app’s state and can be sent actions that mutate it over time.

12:19

We can create a store by specifying an initial state for the screen to start in, the reducer that drives its logic, and the environment of dependencies the screen is running in: struct TCAFormView_Previews: PreviewProvider { static var previews: some View { NavigationView { TCAFormView( store: Store( initialState: SettingsState(), reducer: settingsReducer, environment: SettingsEnvironment() ) ) } } }

12:55

We now have a preview running, but it’s not functional yet because we haven’t fully implemented the reducer.

13:28

Let’s start with the most basic of functionality, which is updating state based on the actions sent from the various UI controls: let settingsReducer = Reducer< SettingsState, SettingsAction, SettingsEnvironment > { state, action, environment in switch action { case let .digestChanged(digest): state.digest = digest return .none case .dismissAlert: state.alert = nil return .none case let .displayNameChanged(displayName): state.displayName = displayName return .none case let .protectMyPostsChanged(isOn): state.protectPosts = isOn return .none case .resetButtonTapped: state = .init() return .none case let .sendNotificationsChanged(isOn): state.sendNotifications = isOn return .none } }

14:40

With that bit of work we now have a more functional form, where the toggles can be toggled and the reset button will reset the form.

15:00

Now I don’t know about you, but this implementation of the settings screen does not put the Composable Architecture in a good light when compared to vanilla SwiftUI. There are two big chunks of boilerplate present that simply are not there in our vanilla SwiftUI view.

15:14

First we had to specify an action for each UI control. Right now there are 4 controls, and so we have 4 cases that each have an associated value for the type of data the control holds. If we needed to add 10 more settings controls to this screen, then we would be forced to add 10 more cases to the enum. That’s going to get annoying.

15:36

Further, the reducer feels kind of boilerplate-y. For each of the 4 actions that handle a UI control we are simply binding to the value in the action and then updating some state with that value. There’s no real logic in there. Just a simple setter.

15:52

The one difference that is positive is the logic for resetting the form. Because the Composable Architecture pushed us to bundle all of our state in a single struct, we’re able to replace all of its data at once, whereas our view model mutates each published field individually. This means that if we were to add a field to our state, the view model has more potential of introducing a bug if we forget to add logic to reset that field as well.

16:19

While we could have bundled our view model’s state into a single property, that would have come with its own complications, like being able to detect when a specific field changes, as we did with the display name.

16:33

To contrast, in vanilla SwiftUI we had minimal boilerplate. Adding a new UI control to this screen is just a matter of adding some state to the view model, and then deriving a binding for that state to hand to the control. And that’s incredibly powerful.

16:47

Now, it’s worth mentioning that on the spectrum of bad to “ok” boilerplate that we feel this is mostly “ok” boilerplate. It is at least statically checked, and so therefore more difficult to get wrong. But even so, zero boilerplate is far better than “ok” boilerplate. Form validation and side effects

17:04

So, the Composable Architecture version of this screen definitely has a bit of a boilerplate problem, and we will soon address it, but before then let’s implement the rest of the settings feature. The vanilla SwiftUI version of this screen had quite a bit of additional complexity when it came to adding more advanced features, such as form validation and notification side effects, and we claim that those things are more straightforward in the Composable Architecture.

17:36

Let’s start with the form validation logic. Right now in the .displayNameChanged action we update the state with the new value, but we can now add a bit of extra logic to make sure that the display name doesn’t grow to more than 16 characters: case let .displayNameChanged(displayName): state.displayName = String(displayName.prefix(16)) return .none

17:59

And that’s it.

18:01

No need to wade into dangerous waters by tapping into the didSet callback, guarding that the string is more than 16 characters, and then performing the mutation. Remember that that is dangerous because you run the risk of creating an infinite loop of mutations if you don’t have your logic just right.

18:25

But with the Composable Architecture we don’t have to worry about any of that. We can perform the transformation right in the reducer, and everything just works as you would expect.

18:32

So that’s form validation. Let’s look at the next complexity we layered onto the settings screen: notifications. In the view model we indiscriminately sprinkled side effects through our code to get the job done. In contrast, the Composable Architecture encourages us to properly design the dependencies a feature needs to do its job, and provide those dependencies via the environment. This is encouraged by the Composable Architecture because the reducer is supposed to be a pure function, which means it should not reach out to global, effectful things like notification center, network requests and more.

19:19

Let’s start by looking at one external dependencies we are using. It seems we are calling out to the current() static method on UNUserNotificationCenter in order to get a global singleton, and currently we only invoke the following two methods on that instance: UNUserNotificationCenter.current().getNotificationSettings … UNUserNotificationCenter.current().requestAuthorization( options: .alert )

19:43

We also reach out to the global UIApplication instance for registering for remote notifications: UIApplication.shared.registerForRemoteNotifications()

19:50

These are obvious dependencies since we are reaching out to globally defined objects in order to fetch data or make a request. There are also more subtle dependencies, such as any time we call out to the main dispatch queue: DispatchQueue.main.async { … }

20:03

These are all dependencies that the Composable Architecture encourages us to control in our environment, and indeed sometimes even forces us.

20:13

Let’s start with the UNUserNotificationCenter dependency. We are going to define a thin client wrapper around this object so that we can use it in a manner that is friendlier towards the Composable Architecture. This is a topic we have talked about a ton on Point-Free, going back to some of our first . Most recently we dedicated a 5-episode series to examining dependencies and finding a way to model them that enhances composability and testability. We can’t recap everything from those episodes, and so we encourage everyone to watch them if you haven’t, but we still go over each step necessary to design these dependencies.

20:55

We begin by creating a struct that will hold all of the dependency’s endpoints we want to call: struct UserNotificationsClient { }

21:09

We will add closure instance variables to this struct that represent the functionality of the dependency. So for example, we know we want to be able to ask the notifications client for its current settings, and so that might look like this: var getNotificationSettings: () -> UNNotificationSettings

21:36

However, this isn’t quite right. The getNotificationSettings method on UNUserNotificationCenter is asynchronous, and returns its value via a callback closure. To make this work with the Composable Architecture we need to instead return an effect that produces the UNNotificationSettings : var getNotificationSettings: () -> Effect<UNNotificationSettings, Never>

22:16

The Effect type is a Combine publisher defined by the Composable Architecture and is generic over the value it can publish and any potential failure. Note that this effect never fails because it seems that the getNotificationSettings method on UNUserNotificationCenter never fails.

22:27

We also want to model the requestAuthorization endpoint as a closure in this struct. This will be a closure that takes an argument, because you specify some options of what kind of authorization you want to request, and it will return an effect: var requestAuthorization: (UNAuthorizationOptions) -> Effect<Bool, Error>

22:48

This effect can produce either a boolean, indicating whether or not authorization was granted, or an error. This is basically what is handed to the callback of the requestAuthorization method on UNUserNotificationCenter .

23:13

With this very basic client defined we can implement a “live” version of the client. That is an instance of the struct that will interact with the real life UNUserNotificationCenter under the hood. We like to house this instance in the UserNotificationsClient type as a static: extension UserNotificationsClient { static let live = Self( getNotificationSettings: <#() -> Effect<UNNotificationSettings, Never>#>, requestAuthorization: <#(UNAuthorizationOptions) -> Effect<Bool, Error>#> ) }

23:46

For the getNotificationSettings endpoint we can open up a closure and return an effect. The Composable Architecture comes with a few helpers to construct certain types of effects. When you need an effect that needs to emit a single time asynchronously you can use the .future static method: getNotificationSettings: { Effect.future { callback in } }

24:13

Then in here we can perform the asynchronous work and feed it back to the callback : getNotificationSettings: { Effect.future { callback in UNUserNotificationCenter.current() .getNotificationSettings { settings in callback(.success(settings)) } } }

24:54

We can do something similar for requestAuthorization , except we need to handle two separate cases before calling the callback : if the error is present we will the effect, and otherwise we will pass along the granted boolean: requestAuthorization: { options in Effect.future { callback in UNUserNotificationCenter.current() .requestAuthorization(options: options) { granted, error in if let error = error { callback(.failure(error)) } else { callback(.success(granted)) } } } }

25:50

That is it for a very basic, thin wrapper around UNUserNotificationCenter . There is a lot more to the notifications API that we might want to capture in this client, and so there’s more work to be done to make this usable for production, but it will get us going for now.

26:09

With that client created, let’s add it to our settings environment: struct SettingsEnvironment { var userNotifications: UserNotificationsClient }

26:20

This will break the preview, but we can just update the preview to use the live UserNotificationsClient : environment: SettingsEnvironment( userNotifications: .live )

26:34

Now in the reducer we are free to use these dependencies. And because the dependencies are modeled on Combine publishers, they combine in all types of interesting ways.

26:49

In the .sendNotificationsChanged action we will stop mutating the state wholesale. We want special logic around notification permissions when switching this option on. But before that we can first check if we are flipping notifications off, because in that case we can just mutate and be done with it: case let .sendNotificationsChanged(sendNotifications): // state.sendNotifications = sendNotifications // return .none guard isOn else { state.sendNotifications = sendNotifications return .none }

27:13

If we get past this guard then we need to return an effect that can reach to the outside world. First it will fetch the current notification settings, and if permission has not be previously denied then we will further request authorization. If we were previously denied then there’s nothing to do.

27:34

We can start by constructing the effect that retrieves the notification settings: return environment.userNotifications.getNotificationSettings()

27:45

We want to feed the result of this effect back into the system so that we can check if we have been denied or not, and potentially fire off a new effect.

28:13

So, we’ll add a new action that receives the settings from this effect: enum SettingsAction { … case notificationSettingsResponse(UNNotificationSettings) … }

28:29

And then .map on the effect to bundle it up into that action: return environment.userNotifications.getNotificationSettings() .map(SettingsAction.notificationSettingsResponse)

28:57

Then we need to implement the logic for this action in the reducer, which will largely depend on the .authorizationStatus of the settings handed to us: case let .notificationSettingsResponse(settings): switch settings.authorizationStatus { case .notDetermined: <#code#> case .denied: <#code#> case .authorized: <#code#> case .provisional: <#code#> case .ephemeral: <#code#> @unknown default: <#code#> }

29:35

For a few of these cases we just want to optimistically flip the sendNotifications field to true , and then fire off a new effect to actually request authorization. This can be done by bundling all of those cases together into a single block: case .notDetermined, .authorized, .provisional, .ephemeral: state.sendNotifications = true return environment.userNotifications.requestAuthorization(.alert)

30:24

The effect we are returning right now isn’t quite right though. It is an effect that can output a boolean but can also fail with an error. The Composable Architecture forces us to return effects that do not error, ensuring that we handle failures explicitly, but this publisher can error since requestAuthorization can error.

30:47

A common way to handle this in the Composable Architecture is to bundle the output and error into a Result value, and the effect outputs that and never fails. The library even provides a special operator just for that called .catchToEffect : case .notDetermined, .provisional, .ephemeral, .authorized: state.sendNotifications = true return environment.userNotifications.requestAuthorization(.alert) .catchToEffect() Cannot convert return expression of type ‘Effect<Result<Bool, Error>, Never>’ to return type ’Effect<SettingsAction, Never>’

31:01

Now we have an effect that produces a Result<Bool, Error> , but still isn’t the right type to return. The reducer must return an effect that emits the type of actions the reducer handles. This is because the main function of a reducer’s effects is to feed the result of effects back into the system, and the way to do that is through actions.

31:18

This means we need to add a new action to our SettingsAction enum: enum SettingsAction { case authorizationResponse(Result<Bool, Error>) … }

31:27

And then mapping on our effect to bundle the output into this enum case finally puts our effect into the right shape to return from the reducer: case .notDetermined, .provisional, .ephemeral, .authorized: state.sendNotifications = true return environment.userNotifications.requestAuthorization(.alert) .catchToEffect() .map(SettingsAction.authorizationResponse)

31:37

And then for the denied case we want to switch the notifications back to false and show an alert: case .denied: state.sendNotifications = false state.alert = .init( title: "You need to enable permissions from iOS settings" ) return .none

31:58

We don’t know what to do with the @unknown default so we can just return no effect: @unknown default: return .none

32:20

Since we’ve added a new action to our enum we have broken the exhaustive switch in the reducer, which is a good thing because it forces us to handle the new action. We can handle the .failure and .success cases separately: case .authorizationResponse(.failure): state.sendNotifications = false return .none case let .authorizationResponse(.success(granted)): state.sendNotifications = granted return .none

33:20

Amazingly this is all it takes to get the basics of the notifications flow in place.

33:25

Remember that user notifications do not work in Xcode previews, so let’s delete the currently installed app and update its entry point to use our new form powered by the Composable Architecture. import ComposableArchitecture @main struct ConciseFormsApp: App { var body: some Scene { WindowGroup { NavigationView { // VanillaSwiftUIFormView() TCAFormView( store: Store( initialState: SettingsState(), reducer: settingsReducer, environment: .live ) ) } ) } }

33:55

When we run things in the simulator we will see that if we tap the toggle we get a permissions prompt and the toggle did not yet turn on. Then if we tap “Don’t allow” the alert will go away and the toggle stays in the off position. Tapping on the toggle doesn’t do anything yet, but we will get to that in a moment.

34:09

Let’s also test the happy path. If we delete and reinstall the app, toggle the notifications, and then allow permission, we will see that the toggle flips to on, but we also got one of those thread warnings: Publishing changes from background threads is not allowed; make sure to publish values from the main thread (via operators like receive(on:)) on model updates.

34:33

This is the same warning we came across with our view model. Back then we needed to make sure that all view model mutations happen on the main queue, and here we have to make sure that all effect emissions happen on the main queue. This can be done easily enough with a Combine operator: return environment.userNotifications.requestAuthorization(.alert) .receive(on: DispatchQueue.main) .catchToEffect() .map(SettingsAction.authorizationResponse) … return environment.userNotifications.getNotificationSettings() .receive(on: DispatchQueue.main) .eraseToEffect() .map(SettingsAction.notificationSettingsResponse)

35:27

That will guarantee that every value emitted by the effect is fed back into the store on the main thread.

35:53

However, we have also introduced an unnecessary side effect to our reducer. By reaching out to the global singleton DispatchQueue.main we have given up control of our code. We are now at the mercy of how dispatch queues work, and in particular this means that it is hard to test this code, but there are also other downsides.

36:14

Well, no worries, the Composable Architecture comes with a way to take back control of your code. Instead of using a live dispatch queue in your reducer you can use a type-erased scheduler, which will be a dispatch queue when running the live app, but can be a test scheduler or immediate scheduler in tests.

36:33

We spent [several episodes] last year on Combine’s Scheduler protocol, so if any of our viewers is interested in how they work, they should check out those episodes.

36:45

So, let’s add a scheduler to our environment: struct SettingsEnvironment { var mainQueue: AnySchedulerOf<DispatchQueue> var userNotifications: UserNotificationsClient }

37:12

And update the environment used for our previews and main app entry point: environment: SettingsEnvironment( mainQueue: DispatchQueue.main.eraseToAnyScheduler(), userNotifications: .live )

37:37

And now we can swap out the live dispatch queues for the scheduler: return environment.userNotifications.requestAuthorization(.alert) .receive(on: environment.mainQueue) .catchToEffect() .map(SettingsAction.authorizationResponse) … return environment.userNotifications.getNotificationSettings() .receive(on: environment.mainQueue) .eraseToEffect() .map(SettingsAction.notificationSettingsResponse)

37:56

Everything should work as before, but now we won’t get that warning about calling SwiftUI code on a non-main thread.

38:08

There is only one last bit of functionality we need to implement in order to have full parity with what we built using the view model. Once we have been granted notification authorization we need to further register for remote notifications. This is done by calling the .registerRemoteNotifications() method on the UIApplication.shared singleton.

38:32

We will add this endpoint to the UserNotificationsClient struct, and it will be an effect that returns an Effect<Never, Never> because it does not emit any values that need to be fed back into the reducer, and it cannot fail. It’s just something that fires off into the void and then we can forget about it. struct UserNotificationsClient { … var registerForRemoteNotifications: () -> Effect<Never, Never> … }

39:10

We now need to update the live instance of our dependency with this endpoint and call out to UIApplication under the hood. The easiest way to create this effect is via the .fireAndForget helper static method that we have on the Effect type: extension UserNotificationsClient { static let live = Self( … registerForRemoteNotifications: { .fireAndForget { UIApplication.shared.registerForRemoteNotifications() } }, … ) }

39:39

And now when we get the .authorizationResponse callback we can register for remove notifications if we have been granted authorization: case let .authorizationResponse(.success(granted)): state.sendNotifications = granted return granted ? environment.userNotifications.registerForRemoteNotifications() .fireAndForget() : .none

40:26

And with that we have fully recovered the logic from our previous version that uses a view model.

40:43

Now we are starting to see some of the strengths of the Composable Architecture. While it was true that some of the simple things came with boilerplate, the more complicated come naturally to us due to how the library wants you to build your features. The reducer is supposed to be a simple, pure function that cannot perform side effects. If you need to perform a side effect you must return an effect, which will later be run by the store and its outputs will be fed back into the system. This led us to properly model our dependencies and we even got a nice little side benefit out of it in that our code is a lot less nested, and it’s much clearer how the data flows through the logic. Simulating dependencies in Xcode previews

41:29

But the real benefits of writing in this style come when you want to write tests for your feature. The view model we wrote earlier is in a half testable state. We can certainly test things like the reset method, where calling it should reset all the state in the view model. But we can’t test the notifications flow easily because it reaches out to global singletons that we do not control, like UNUserNotificationCenter.current() and UIApplication.shared`.

41:59

However, testing this feature in the Composable Architecture is quite straightforward. Just to get warmed up for writing our tests, let’s show that by properly designing our dependencies we have unlocked a whole new level of control over our feature.

42:12

As we discussed before, the moment we started depending on UserNotifications functionality we severely limited the usefulness of our SwiftUI previews because that framework doesn’t work in previews. This makes it harder to to see all the states and flows of the UI. You are forced to run in a simulator.

42:32

But our feature built with the Composable Architecture has no such problems. We can simply put in a non-live version of the dependency when running the preview, such as a version of the notifications client that behaves as if the user denied permissions.

42:46

To start we can create a new UserNotificationsClient instance to pass into the environment for our preview: // userNotifications: .live userNotifications: .init( getNotificationSettings: <#() -> Effect<UNUserNotificationSettings, Never>#>, registerForRemoteNotifications: <#() -> Effect<Never, Never>#>, requestAuthorization: <#(UNAuthorizationOptions) -> Effect<Bool, Error>#> )

43:23

Just to get things compiling we could simply stub in some fatal errors for these closures: userNotifications: .init( getNotificationSettings: { fatalError() }, registerForRemoteNotifications: { fatalError() }, requestAuthorization: { _ in fatalError() } )

43:33

That will allow us to run the preview, but of course as soon as we tap on the notifications toggle it will crash.

43:39

So what we want to do is implement some of these endpoints so that they act as if they user had previously denied permissions. For example, in the .getNotificationSettings endpoint we could just return an effect that immediately emits a settings value that has the authorizationStatus boolean flipped to true : getNotificationSettings: { Effect(value: UNNotificationSettings.init(???)) },

44:08

Unfortunately that’s not possible right now. We would need to construct a UNNotificationSettings object, which sadly Apple does not provide a public initializer for, even though it’s just a simple object with a few basic fields.

44:19

This is something we discussed quite a bit in our series on designing dependencies . When we want to control 3rd party dependencies we will often come across types that we aren’t allowed to construct, and so what we need to do is create a lightweight wrapped around them.

44:36

We can start by updating the getNotificationSettings effect to emit values of a type we own, which can be defined as a new struct inside the UserNotificationsClient . It will hold onto the fields of UNNotificationSettings that we care about, which is just authorizationStatus for right now. struct UserNotificationsClient { var getNotificationSettings: () -> Effect<Settings, Never> … struct Settings { var authorizationStatus: UNAuthorizationStatus } }

45:06

If later we wanted more fields from UNNotificationSettings we would just add them here.

45:10

We also need a way to create this new Settings type from a UNNotificationSettings : extension UserNotificationsClient.Settings { init(rawValue: UNNotificationSettings) { self.authorizationStatus = rawValue.authorizationStatus } }

45:35

This breaks our live instance because we now need to create a value of this new Settings type from the UNNotificationSettings value: getNotificationSettings: { Effect.future { callback in UNUserNotificationCenter.current() .getNotificationSettings { settings in callback(.success(.init(rawValue: settings))) } } },

45:49

And we need to update the action that receives the notification settings to use our new Settings type: case notificationSettingsResponse(UserNotificationsClient.Settings)

46:01

With that done our application should build and function just as before, but now we can finish creating the dependency that fakes as if notification permissions were denied: getNotificationSettings: { Effect(value: .init(authorizationStatus: .denied)) },

46:19

If we run the preview we will see that if we tap on the notifications toggle we get the alert telling us to update our iOS settings. We don’t even need to implement the other endpoints. They can stay as fatalErrors because if getNotificationSettings tells us we have denied authorization then we should never call those other endpoints.

46:40

The thing we are witnessing here in the preview was previously impossible with the view model we had written. The only way to see this functionality in the view model would have been to run the application in the simulator. So that’s pretty amazing. A basic test

46:55

Now that we’ve warmed up, let write a test. We can start by testing the logic that when we change the display name to be more than 16 character it gets truncated. It starts by constructing what is known as a TestStore which allows us to send actions into the system and assert on how the state evolves.

47:07

To construct the test store we need to specify an initial state, reducer and environment: import ComposableArchitecture import XCTest @testable import ConciseForm class ConciseFormsTests: XCTestCase { func testBasics() { let store = TestStore( initialState: <#_#>, reducer: <#Reducer<_, _, _>#>, environment: <#_#> ) } }

47:45

The initial state and reducer can be provided easily enough: initialState: SettingsState(), reducer: settingsReducer, To construct an environment we need to specify the mainQueue dependency and the userNotifications dependency: environment: SettingsEnvironment( mainQueue: <#AnySchedulerOf<DispatchQueue>#>, userNotifications: <#UserNotificationsClient#> )

48:08

For the main queue we need to provide a scheduler. We could use a live DispatchQueue , but then that would mean we have to add waiting to our test so that brief amounts of time can pass by for effects to run. Luckily the Composable Architecture comes with two schedulers that give us more control over how work is executed in our effects. There’s the TestScheduler where we can explicitly control the flow of time in the scheduler, and there’s the ImmediateScheduler where work is just run immediately.

48:39

We’ll use the ImmediateScheduler since the only purpose of the scheduler is for the .receive(on:) operator, and so ideally that work can just execute as quickly as possible: environment: SettingsEnvironment( mainQueue: DispatchQueue.immediateScheduler.eraseToAnyScheduler(), userNotifications: <#UserNotificationsClient#> )

49:03

For the userNotifications dependency we need to construct an actual UserNotificationsClient to be passed in here. However, the logic we are testing now doesn’t make any use of that dependency. We should be able to put in a value that simply fatalError s on all its endpoints and the test should still pass. environment: SettingsEnvironment( mainQueue: DispatchQueue.immediateScheduler.eraseToAnyScheduler(), userNotifications: .init( getNotificationSettings: { fatalError() }, registerForRemoteNotifications: { fatalError() }, requestAuthorization: { _ in fatalError() } ) ) Generic class ‘TestStore’ requires that ‘SettingsAction’ conform to ‘Equatable’

49:27

Test stores assert against not only mutations to state over time, but also against actions received through side effects, this means that our app state and actions must conform to Equatable . We already made SettingsState equatable for its view store, so let’s do the same for SettingsAction : enum SettingsAction: Equatable { … } Type ‘SettingsAction’ does not conform to protocol ‘Equatable’

49:54

To get an automatically synthesized conformance, all of an enum’s associated values must also conform, but we have a couple cases with values that do not. It’s easy enough to add a conformance to UserNotificationsClient.Settings : struct UserNotificationsClient { … struct Settings: Equatable { … } }

50:24

But we also have authorizationResponse , which holds Result<Bool, Error> . Results are only equatable when both of their generics are equatable, and the Error protocol is not. To get around this, we can lean on the fact that all Error s can be cast to NSError , and NSError s are equatable. case authorizationResponse(Result<Bool, NSError>)

50:42

We just need to map the publisher’s error: return environment.userNotifications.requestAuthorization(.alert) .receive(on: environment.mainQueue) .mapError { $0 as NSError } .catchToEffect() .map(SettingsAction.authorizationResponse)

50:58

Everything’s now compiling in both our app and tests, and we can finally write an assertion. All that takes is to invoke the .assert method on the test store and provide a list of actions you want to send to the store, along with closures that describe the exact mutation that should have been made to the store. store.assert( .send(.displayNameChanged("Blob")) { $0.displayName = "Blob" }, .send(.displayNameChanged("Blob McBlob, Esq.")) { $0.displayName = "Blob McBlob, Esq" } )

52:33

That’s all it takes, and this test passes.

52:35

We can also through in a few more actions just to make sure when we toggle the “Protect my posts” or change the digest type we get the state mutations we expect: .send(.protectMyPostsChanged(true)) { $0.protectMyPosts = true }, .send(.digestChanged(.weekly)) { $0.digest = .weekly }

53:16

We’re beginning to see many of the nice things that come from working with the Composable Architecture. We were encouraged to control our dependencies, which restored our ability to use Xcode previews to simulate certain states that were not possible in a simple view model, and writing tests is generally a nice experience. A more advanced test

53:29

Testing the notifications flow is a bit more difficult because it involves effects, so let’s start a new test for that.

53:53

We’ll start with the happy path where the user gives permissions for notifications: func testNotifications_HappyPath() { let store = TestStore( initialState: SettingsState(), reducer: settingsReducer, environment: SettingsEnvironment( mainQueue: DispatchQueue.immediateScheduler .eraseToAnyScheduler(), userNotifications: .init( getNotificationSettings: { fatalError() }, registerForRemoteNotifications: { fatalError() }, requestAuthorization: { _ in fatalError() } ) ) ) }

54:45

Right now we are just using a fatal-erroring user notifications client, but soon we will properly override its endpoints.

54:55

Let’s start by simulating the user tapping on the toggle to turn notifications on: store.assert( .send(.sendNotificationsChanged(true)) )

55:10

We don’t expect any state to change, but we do expect an effect to be fired off. In fact, if we run tests now we’ll get caught in the fatalError that is inside getNotificationSettings . So, we need to override that with something appropriate. To do that we can create a value of UserNotificationsClient where getNotificationSettings returns a .notDetermined authorization status, and all the other endpoints remain fatalError s: getNotificationSettings: { .init(value: .init(authorizationStatus: .notDetermined)) },

55:54

When run tests again we will now get caught on the fatalError in requestAuthorization because as soon as the reducer saw that our authorization status was .notDetermined it immediately tried requesting authorization. So, to get past that fatalError we need to override that endpoint, and because we are testing the happy path we can just return a successful value of true to emulate that the user gave us permission: requestAuthorization: { _ in .init(value: true) }

56:25

Now when we run tests we get caught on the final fatalError in the client, which is the registerForRemoteNotifications endpoint. This is happening because as soon as the reducer sees that it has been granted notification permissions it immediately tries registering for remote notifications.

56:41

It’s easy enough to stub out that endpoint. Since registerForRemoteNotifications is a fire-and-forget effect we can just return .fireAndForget : registerForRemoteNotifications: { .fireAndForget { } },

57:09

But the fact that this effect was executed will not be captured in our test at all since it doesnt emit any output. The other two endpoints had actual data it sent back into the system, which allows us to verify that they worked as we expected.

57:20

So to strengthen the test a little bit we will have a mutable boolean that will determine whether or not this effect was executed: var didRegisterForRemoteNotifications = false … registerForRemoteNotifications: { .fireAndForget { didRegisterForRemoteNotifications = true } },

57:43

Now when we run tests we finally get past all of the fatalError s and instead we have a few test failures: Received 2 unexpected actions: … Unhandled actions: [ SettingsAction.notificationSettingsResponse( Settings( authorizationStatus: UNAuthorizationStatus.UNAuthorizationStatus ) ), SettingsAction.authorizationResponse( Result<Bool, NSError>.success( true ) ), ] These are really good failures to have. We are getting these failures because after sending the .sendNotificationsChanged action some effects were executed and they fed actions back into the system. We have to be exhaustive in our store assertion by explicitly describing all of the actions that go through the system and their respective state mutations.

58:04

So, for example, after sending .sendNotificationsChanged we expect that we will sometime later receive an action from an effect that gives us the user’s notification settings, which we know should be .notDetermined : .receive( .notificationSettingsResponse( .init(authorizationStatus: .notDetermined) ) )

58:31

Further, when we receive that action we know that state will change because we optimistically turn the sendNotification boolean to true while we ask the user for permission: .receive( .notificationSettingsResponse( .init(authorizationStatus: .notDetermined) ) ) { $0.sendNotifications = true }

58:52

Then, after receiving this action we expect to receive another action a moment later which determines whether or not the user granted us permission. Since this is the happy path we know that indeed we will be given permission: .receive(.authorizationResponse(.success(true)))

59:19

After receiving that action there is nothing else going on in the system, and so there’s nothing left to assert. We can run tests and they all pass. But we haven’t made use of the didRegisterForRemoteNotifications we created above. After this assertion finishes we should further assert that the boolean flipped to true : XCTAssertEqual(didRegisterForRemoteNotifications, true)

59:37

This is a pretty comprehensive test that would have been impossible to write with the way the view model version of the code is written. By doing just a bit of upfront work we can get a ton of code coverage.

59:52

With just a little more work we can also write a test for the unhappy path, where the user denies us permission to their notifications, but we will save that as an exercise for the viewer. Next time: eliminating boilerplate

1:00:06

OK, so we have done a really comprehensive overview of how forms work in vanilla SwiftUI applications and in Composable Architecture applications. There really doesn’t seem to be a clear winner as far as conciseness goes. On the one hand SwiftUI handles very simple forms amazingly, reducing boilerplate and noise, but things get messy fast when you are handling more complex, real world scenarios. On the other hand the Composable Architecture comes with a decent amount of boilerplate for a very simple form, but then really shines as you start layering on the complexities, giving you a wonderful story for dependencies, side effects and testing.

1:00:44

So, this is a bit of a bummer. We love the Composable Architecture, but it’s things like this boilerplate problem which can turn away people from using it even when there are so many other benefits to be had.

1:00:57

Well, luckily for us the boilerplate problem can be solved. Using some of the more advanced features of Swift we can eliminate almost all of the boilerplate when dealing with simple form data, which will hopefully make the Composable Architecture solution more palatable for those worried about boilerplate.

1:01:16

So let’s take all of the work we’ve done with the Composable Architecture and copy it over and chip away at the problem of eliminating that boilerplate…next time! References Collection: Dependencies Brandon Williams & Stephen Celis Note Dependencies can wreak havoc on a codebase. They 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. https://www.pointfree.co/collections/dependencies Downloads Sample code 0132-concise-forms-pt2 Point-Free A hub for advanced Swift programming. Brought to you by Brandon Williams and Stephen Celis . Content Become a member The Point-Free Way Beta previews Gifts Videos Collections Free clips Blog More About Us Community Slack Mastodon Twitter BlueSky GitHub Contact Us Privacy Policy © 2026 Point-Free, Inc. All rights are reserved for the videos and transcripts on this site. All other content is licensed under CC BY-NC-SA 4.0 , and the underlying source code to run this site is licensed under the MIT License .