EP 244 · Tour of the Composable Architecture · Aug 7, 2023 ·Members

Video #244: Tour of the Composable Architecture: Introducing Standups

smart_display

Loading stream…

Video #244: Tour of the Composable Architecture: Introducing Standups

Episode: Video #244 Date: Aug 7, 2023 Access: Members Only 🔒 URL: https://www.pointfree.co/episodes/ep244-tour-of-the-composable-architecture-1-0-standups-part-1

Episode thumbnail

Description

We continue our tour of the Composable Architecture 1.0 by rebuilding one of Apple’s most complex sample projects: Scrumdinger. We will create our own “Standups” app using the tools of the Composable Architecture.

Video

Cloudflare Stream video ID: 19600e9b7105844767927a7e27a61da6 Local file: video_244_tour-of-the-composable-architecture-1-0-standups-part-1.mp4 *(download with --video 244)*

References

Transcript

0:05

Stephen OK, so that concludes our “soft” landing into the Composable Architecture, though we did end up exploring quite a few advanced topics already. We implemented a few side effects, including a network request and a timer, and we already came face-to-face with the gnarly beast known as “dependencies.” They can wreak havoc on your code, and they made it very difficult for us to unit test our application, and so we saw what it takes to control our dependencies rather than letting them control us. Brandon

0:28

And so we are now going to move on to explore Apple’s Scrumdinger application, but we also want all of our viewers to know that the Composable Architecture repo comes with lots of demos and case studies that explore other kinds of problems one faces day-to-day. We highly encourage our viewers to check out those examples to get even more comfortable with the foundations of the library.

0:48

We are going to start by giving a tour of the Scrumdinger application so that everyone knows what is we will be building.

0:56

So, let’s get started.

0:58

The Scrumdinger application is a full blown tutorial that Apple released. We can open it in Safari:

1:08

From the first page of the tutorial we learn: Note This module guides you through the development of Scrumdinger, an iOS app that helps users manage their daily scrums.

1:20

And if we look at the table of contents we will see that this demo encompasses an impressive number of topics. It’s got views, navigation, state management, persistence, drawing and even recording audio.

1:33

We have the final project already downloaded and opened. Let’s run it in the simulator to see what all it can do.

1:41

The app launches into an empty list view. Right now there is only one action we can take, and that’s to tap the “+” button for adding a new daily scrum meeting.

1:50

Tapping the “+” button brings up a sheet with a form that allows us to edit the details of the meeting. We can set a title, duration, theme, and we can add attendees by name. Further we have the choice of dismissing the sheet without adding the meeting, or we can tap “Add” to actually add it. Let’s go ahead and a new meeting for “Point-Free”, and we will add “Brandon” and “Stephen” to the attendees list…

2:15

Now one weird thing about this attendee interface is that you have to actually hit the blue “+” button to add the attendee. This means if we tapped “Add” in the top-right, Stephen wouldn’t actually be an attendee. Also the previous attendees aren’t editable at all. These are minor user experience annoyances, and we’re going to take the time to fix some of them as we recreate this application.

2:40

Now let’s tap the “Add” button, and we will see the sheet dismiss and the new meeting was added to the of the list.

2:46

The only other action we can take on this screen is to tap a row to drill-down to the meeting detail. So, let’s go to the “Point-Free” meeting.

2:57

There are a few actions we can take on the screen.

2:59

First, we can hit “Edit” to bring up a sheet with a form that allows us to edit any of the details of the meeting. One interesting thing about this modal is that we can make changes to the data and then hit cancel, and we will see those changes were not saved. This must mean this screen manages a bit of scratch data so that it can make changes without changing the original source of truth.

3:28

Another action we can take on the detail screen is to start the meeting. That will drill down to a new screen with a timer going and letting you know whose turn it is to give their update. The screen will even record the audio from your meeting and transcribe it so that it can be referenced at a later time. That’s what the history section down below is all about. We just need to grant permission.

3:45

Each speaker gets equal time of the total minutes for their update. So once half the seconds elapses we should hear a ding, and then we see that “Stephen” is now the speaker.

4:00

To end the meeting we can manually tap the back button in the top-left:

4:09

OK, and we do indeed see a new meeting as been added to the history.

4:12

Let’s tap the history row to drill down to see what’s inside, and we see a transcript of what was said during the meeting.

4:27

So, we’ve now created a daily scrum and recorded a meeting in the scrum. Let’s close the app in the simulator and then relaunch the application:

4:40

We will see that our scrum is already in the list, so the app must have some mechanism for persisting data to disk and loading it on launch. We can even drill down to the scrum to see that the history was also persisted.

4:45

OK, that is the entirety of the app. It’s only 4 screens: the root list, the add/edit screen, the detail screen, and the recording screen. So, it seems somewhat simple, but there are some really interesting interactions happening.

4:57

There’s the interaction where the edit screens works on a scratch piece of data and the changes are only committed if the “Save” button is tapped.

5:04

There is complex logic around navigation, such as when the meeting timer ends we should pop the screen off the stack.

5:11

The application uses a complex Apple framework in order to live transcribe audio to text.

5:18

And finally, the application persists the data to disk.

5:23

This is just a really fun application, and I think perfectly demonstrates a lot of real world problems that one must solve when building applications. Introducing Standups Stephen

5:31

So, let’s start rebuilding this app, and along the way we will compare and contrast how using the Composable Architecture differs from using vanilla SwiftUI.

5:41

I’ve got a brand new project ready for us, but I’ve made a few key changes from Apple’s template project. First, we’ve decided to name this project “Standups” just to differentiate ourselves a little bit. It’s nothing against scrum, we just like “standups” better.

5:55

Second we have maxed out the concurrency warnings: SWIFT_STRICT_CONCURRENCY = complete

6:00

We going to make use of Swift’s concurrency tools in the upcoming episodes, and so we want to be notified as early as possible when we are doing something that is incorrect.

6:07

And lastly, I have the 1.0 version of the Composable Architecture already added to the project so it’s ready for us whenever we need it.

6:17

Recall that the entry point of the Scrumdinger app housed a navigation view and at the root of that view was the list of scrums. Let’s create a new file for the list of standups.

6:28

Let’s paste in some very basic scaffolding for a view that will hold a list of standups eventually, as well as its preview: import SwiftUI struct StandupsListView: View { var body: some View { List { } .navigationTitle("Daily Standups") .toolbar { ToolbarItem { Button("Add") {} } } } } #Preview { NavigationStack { StandupsListView() } }

7:05

OK, we now have our very first view to start putting in some real visuals.

7:10

In order to have some data to provide to this view we are going to need to implement a Composable Architecture domain, but in order to do that we need to have some data types that describe the very basics of the Standups app. We will steal these types from the Scrumdinger code base almost verbatim, but with a few very small changes: import SwiftUI struct Standup: Equatable, Identifiable, Codable { let id: UUID var attendees: [Attendee] = [] var duration = Duration.seconds(60 * 5) var meetings: [Meeting] = [] var theme: Theme = .bubblegum var title = "" var durationPerAttendee: Duration { self.duration / self.attendees.count } } struct Attendee: Equatable, Identifiable, Codable { let id: UUID var name = "" } struct Meeting: Equatable, Identifiable, Codable { let id: UUID let date: Date var transcript: String } enum Theme: String, CaseIterable, Equatable, Hashable, Identifiable, Codable { case bubblegum case buttercup case indigo case lavender case magenta case navy case orange case oxblood case periwinkle case poppy case purple case seafoam case sky case tan case teal case yellow var id: Self { self } var accentColor: Color { switch self { case .bubblegum, .buttercup, .lavender, .orange, .periwinkle, .poppy, .seafoam, .sky, .tan, .teal, .yellow: return .black case .indigo, .magenta, .navy, .oxblood, .purple: return .white } } var mainColor: Color { Color(self.rawValue) } var name: String { self.rawValue.capitalized } }

7:34

We’ve renamed a few things, like DailyScrum to Standup , and we have decided to represent the duration of meetings using the new Duration data type in Swift 5.7. Otherwise everything is pretty much the same.

7:46

There’s a type that represents a meeting, and it has many attendees and many meetings. An attendee is just a type with a name and ID, and a meeting is a type with a date, transcript and ID.

7:57

Notice that each of the 3 data models is Identifiable and uses a

UUID 8:19

We can finally start implementing our first Composable Architecture feature. We can start by importing the library: import ComposableArchitecture

UUID 8:30

Next, just as we did with our counter feature earlier, we will define a new type that conforms to the Reducer protocol: struct StandupsListFeature: Reducer { }

UUID 8:40

And we can quickly stub out its 3 main requirements: struct StandupsListFeature: Reducer { struct State { } enum Action { } var body: some ReducerOf<Self> { Reduce { state, action in switch action { } } } } Recall that the State type holds all the data the features needs to do its job, both for the view as well potentially internal state. The Action type holds all the actions that the user can perform in the view, as well as any actions that may be emitted from effects. And the body property composes together all the reducers that power the logic and behavior of the feature.

UUID 9:13

OK, so we now have the scaffolding of a Composable Architecture feature in place. But, it’s pretty much empty. Next we can do a domain modeling exercise in order to figure out what to put in the State and Action types. Right now the only state I can think of for this feature is an array of Standup values to power the list. So, we might model this like so: struct State { var standups: [Standup] = [] }

UUID 9:39

However, using a plain array here is problematic for a few reasons.

UUID 9:40

A plain array forces us to refer to elements in the array by their positional index, which is not a stable identifier. It is often the case that some work occurs in a row that causes some asynchronous work to be performed, and then when that work finishes you want to update or remove that element from the array. However, by the time you get around to updating or removing, the element may have moved to a different position or already been removed, and so the positional index you are referring to may be invalid. That can lead you to update the wrong row or possibly even crash.

UUID 10:10

And this problem affects all SwiftUI apps, not just those built in the Composable Architecture. And for that reason we built a type specifically tuned for dealing with collections of identified values, called identified array. And that library automatically comes with the Composable Architecture and so we can start using it immediately: struct State { var standups: IdentifiedArrayOf<Standup> = [] }

UUID 10:39

For all intents and purposes identified arrays behave essentially the same as plain arrays, but they allow you to modify and remove elements via their stable ID rather than positional index. We will see how this is useful later on.

UUID 10:51

And currently I don’t think there is any other state we need to add, so let’s move onto actions. If we look at the preview there is only one single button on the entire screen, and so maybe that is the only action we need right now: enum Action { case addButtonTapped }

UUID 11:08

Recall that we like to name our actions literally after what the user did in the UI rather than what logic will be executed by the reducer. So we are going with the very explicit addButtonTapped rather than something like insertStandup .

UUID 11:21

Once we do that we get a compiler error letting us know that we now have to handle this case. Eventually we want tapping this button to cause a sheet to fly up so that you can enter the details of a new meeting. However, that’s going to take time to implement.

UUID 11:37

So, for the time being, and just to get a small amount of functionality in the feature, let’s have it so that when you tap the “Add” button we append an empty standup with a random theme assigned: switch action { case .addButtonTapped: state.standups.append( Standup( id: UUID(), theme: .allCases.randomElement()! ) ) return .none }

UUID 12:21

OK, things are compiling, and we haven’t really accomplished all that much yet, but there will be a lot more exciting things happening in this feature reducer soon enough.

UUID 12:31

But, for now, let’s move over to the view. Just as we did with the counter feature built earlier, we will power this view with a Store , and so the first step is adding a Store as a let property: struct StandupsListView: View { let store: StoreOf<StandupsListFeature> … }

UUID 12:47

Recall that the Store represents the runtime of the feature. It is the thing that is responsible for actually mutating the feature’s state when actions are sent, executing the side effects, and feeding their data back into the system.

UUID 12:58

Next we need to actually observe the store so that we can access the state inside. To do this we use the WithViewStore view to construct a view store: var body: some View { WithViewStore( self.store, observe: <#(StandupsListFeature.State) -> ViewState#> ) { viewStore in … } }

UUID 13:11

The observe closure is our opportunity to decide what state we want to actually observe. Often you do not need to observe everything , and in this case we actually only need the array of standups, so let’s do that: WithViewStore( self.store, observe: \.standups ) { viewStore in … }

UUID 13:26

If you really did need all of state, you can always use the identity closure: WithViewStore(self.store, observe: { $0 }) { viewStore in … }

UUID 13:34

But it is usually best to whittle down state to the bare essentials the view needs, especially the closer the view is to the root of the application.

UUID 13:45

Now that the view store is constructed we can finally access the state from it, such as for the ForEach view in the list, as well as send actions to it, like in the “Add” button: WithViewStore( self.store, observe: \.standups ) { viewStore in List { ForEach(viewStore.state) { standup in } } .navigationTitle("Daily Standups") .toolbar { ToolbarItem { Button("Add") { viewStore.send(.addButtonTapped) } } } }

UUID 14:08

Now that we have a ForEach in place we can implement a view that renders the details of a standup row. I am going to take that view directly from Apple’s Scrumdinger code base. It’s just a simple, inert, behavior-less view that takes a standup as an argument: struct CardView: View { let standup: Standup var body: some View { VStack(alignment: .leading) { Text(self.standup.title) .font(.headline) Spacer() HStack { Label( "\(self.standup.attendees.count)", systemImage: "person.3" ) Spacer() Label( self.standup.duration.formatted(.units()), systemImage: "clock" ) .labelStyle(.trailingIcon) } .font(.caption) } .padding() .foregroundColor(self.standup.theme.accentColor) } } struct TrailingIconLabelStyle: LabelStyle { func makeBody( configuration: Configuration ) -> some View { HStack { configuration.title configuration.icon } } } extension LabelStyle where Self == TrailingIconLabelStyle { static var trailingIcon: Self { Self() } }

UUID 14:25

And now we can stick this CardView into the ForEach : ForEach(viewStore.state) { standup in CardView(standup: standup) .listRowBackground(standup.theme.mainColor) }

UUID 14:54

We finally have enough view hierarchy in place to actually see something on the screen. We can run the preview where we pre-populate the feature’s state with some standups. First let’s fix the preview.

UUID 14:59

Right now it isn’t compiling because we need to create the StandupsList view with a store: #Preview { NavigationStack { StandupsListView() } } Missing argument for parameter ‘store’ in call

UUID 15:06

We can create the store by providing an initial state and the reducer: StandupsListView( store: Store(initialState: StandupsListFeature.State()) { StandupsListFeature() } )

UUID 15:22

And already we can tap the “Add” button a bunch of times to see a bunch of randomly themed standups populate the list.

UUID 15:33

Or we can even start the feature off with some standups pre-populated: initialState: StandupsListFeature.State( standups: <#IdentifiedArrayOf<Standup>#> )

UUID 15:46

To do that easily we will define a mock in the Models.swift file so that we can easily create a standup whenever we want: extension Standup { static let mock = Self( id: Standup.ID(), attendees: [ Attendee(id: Attendee.ID(), name: "Blob"), Attendee(id: Attendee.ID(), name: "Blob Jr"), Attendee(id: Attendee.ID(), name: "Blob Sr"), Attendee(id: Attendee.ID(), name: "Blob Esq"), Attendee(id: Attendee.ID(), name: "Blob III"), Attendee(id: Attendee.ID(), name: "Blob I"), ], duration: .seconds(60), meetings: [ Meeting( id: Meeting.ID(), date: Date().addingTimeInterval(-60 * 60 * 24 * 7), transcript: """ Lorem ipsum dolor sit amet, consectetur \ adipiscing elit, sed do eiusmod tempor \ incididunt ut labore et dolore magna aliqua. Ut \ enim ad minim veniam, quis nostrud exercitation \ ullamco laboris nisi ut aliquip ex ea commodo \ consequat. Duis aute irure dolor in \ reprehenderit in voluptate velit esse cillum \ dolore eu fugiat nulla pariatur. Excepteur sint \ occaecat cupidatat non proident, sunt in culpa \ qui officia deserunt mollit anim id est laborum. """ ) ], theme: .orange, title: "Design" ) }

UUID 15:58

With that we can add the mock to the preview array: initialState: StandupsListFeature.State( standups: [.mock] )

UUID 16:05

And we can now see some UI in the preview right away without doing any extra steps. Building the standup form

UUID 16:09

And just like that we have our first visuals on the screen!

UUID 16:12

But of course, none of what we have done so far is very impressive. In fact, had we stuck with just plain, vanilla SwiftUI, we could have implement what we have so far much faster. No need to fuss around with reducers, state, actions, stores, and view stores. Brandon

UUID 16:24

Well, that’s true, but that’s because we have yet to implement any real logic or behavior in our feature. That is where the Composable Architecture really shines, because it has compelling stories for composing lots of little features into bigger features, managing side effects, controlling dependencies, and writing wonderful, exhaustive tests on top of all of that.

UUID 16:44

So, we have to push a little further in order to start really seeing the benefits of the library.

UUID 16:48

Well, what’s next?

UUID 16:49

Let’s implement the form view that allows you to enter the details for a new standup to be added to the list.

UUID 16:57

We’ll create a new file for this new feature.

UUID 17:04

We will paste a bunch of view code in here just to get some basic scaffolding into place: import SwiftUI struct StandupFormView: View { var body: some View { Form { Section { TextField("Title", text: <#""#>) HStack { Slider(value: <#5#>, in: 5...30, step: 1) { Text("Length") } Spacer() Text(<#"5 min"#>) } ThemePicker(selection: <#.bubblegum#>) } header: { Text("Standup Info") } Section { ForEach(<#attendees#>) { $attendee in TextField("Name", text: $attendee.name) } .onDelete { indices in <#Do something#> } Button("Add attendee") { <#Do something#> } } header: { Text("Attendees") } } } } struct ThemePicker: View { @Binding var selection: Theme var body: some View { Picker("Theme", selection: self.$selection) { ForEach(Theme.allCases) { theme in ZStack { RoundedRectangle(cornerRadius: 4) .fill(theme.mainColor) Label(theme.name, systemImage: "paintpalette") .padding(4) } .foregroundColor(theme.accentColor) .fixedSize(horizontal: false, vertical: true) .tag(theme) } } } } #Preview { MainActor.assumeIsolated { NavigationStack { StandupFormView() } } }

UUID 17:15

We have a bunch of placeholders in here for state that needs to eventually be filled in and action closures that need to send actions, which we will do soon.

UUID 17:29

There isn’t anything particularly interesting in this view code, and you’re not here to watch us painstakingly type up a bunch of SwiftUI code. But, we can run it in the preview to see what the UI looks like.

UUID 17:39

And seeing this preview can help us perform the domain modeling exercise that will help us implement the first half of a reducer conformance. Let’s start the conformance: import ComposableArchitecture struct StandupFormFeature: Reducer { struct State { } enum Action { } var body: some ReducerOf<Self> { Reduce { state, action in switch action { } } } }

UUID 18:15

Looking at the preview we see that we need data for all the fields of a standup, such as its title, duration, theme, and attendees. So, we will just hold onto an entire standup directly in the state: struct State { var standup: Standup }

UUID 18:28

There is another piece of state we model, and this is going to be us going above and beyond compared to Apple’s code sample. It’s something we did in our “ Modern SwiftUI ” series when building Standups in vanilla SwiftUI.

UUID 18:49

We want to be able to precisely control the focus on this screen so that when it first opens the title text field is focused, and further when an attendee is added or removed we want to focus a reasonable field.

UUID 19:10

And so we will add an enum that represents all the things that can be focused: enum Field: Hashable { case attendee(Attendee.ID) case title } Note that we are using the attendee’s ID to represent which field is focused.

UUID 19:40

And then we will add the focus to our state struct: struct State { var focus: Field? var standup: Standup }

UUID 19:51

And while we’re here, let’s go the extra mile one more time. We’d like to require that a standup has at least one attendee, so we will provide a custom initializer such that if the standup provided has no attendees we will add a stub of one: init(focus: Field? = .title, standup: Standup) { self.focus = focus self.standup = standup if self.standup.attendees.isEmpty { self.standup.attendees.append( Attendee(id: UUID()) ) } }

UUID 20:28

OK, that is feeling pretty good so far. Let’s move onto the actions.

UUID 20:31

There are two obvious actions. One is when the “Add attendee” button is tapped: enum Action { case addAttendeeButtonTapped } Again we are being very explicit with the name of this action. Rather than describing what the reducer will eventually do, such as insertAttendee , we are describing what the user does in the UI and then the reducer will interpret that information.

UUID 20:42

And another is when deleting an attendee, which happens in the onDelete modifier applied to the ForEach : enum Action { … case deleteAttendees(atOffsets: IndexSet) }

UUID 21:00

It is given an IndexSet of the row to delete, which comes from the onDelete view modifier down in the view.

UUID 21:16

But there are more actions. Technically there is an action for when the user types into the title text field: enum Action { … case setTitle(String) }

UUID 21:31

As well as setting the duration: enum Action { … case setDuration(Duration) }

UUID 21:38

And setting the theme: enum Action { … case setTheme(Theme) }

UUID 21:42

And even setting the name of an attendee: enum Action { … case setAttendee(id: Attendee.ID, name: String) }

UUID 21:51

This is feeling like a bit much. We are going to have to implement the logic for each of these cases, and they all are going to do the simplest thing possible. And that is, pattern match on the case to bind to the data held in the associated data, and then set the corresponding field on the standup.

UUID 22:10

That’s a ton of boilerplate, and luckily the library comes with a tool to massively streamline this kind of work when dealing with forms like this. There is a way to introduce just a single case to the Action enum that allows you to derive bindings to any field in your state that you want.

UUID 22:31

First, just annotate each field in the state that you want to be able to deriving bindings for in the view with the @BindingState property wrapper: struct State { @BindingState var focus: Field? = .title @BindingState var standup: Standup … }

UUID 22:44

Then conform your action enum to the BindableAction protocol: enum Action: BindableAction { … }

UUID 22:51

And that forces you to add a single case that takes the place of all of those disparate cases we were about to add: enum Action: BindableAction { case binding(BindingAction<State>) … }

UUID 23:10

And that’s all there is for the domain. Next, we have the reducer.

UUID 23:25

And in it there is one extra step needed to implement the logic for these bindings. And this brings us to our first example of “reducer composition” in the Composable Architecture. As you might guess from the name, composition is a big part of the library, and this means the ability to take multiple reducers and cram them together into a large reducer that encompasses all of the smaller features’ logic and behavior.

UUID 23:51

The way one composes reducers is a lot like one “composes” views in SwiftUI. You simply list the reducer right in the body property. In this case we will compose in the BindingReducer that comes with the library: var body: some ReducerOf<Self> { BindingReducer() Reduce { state, action in … } }

UUID 24:19

The BindingReducer will run first, and it takes care of all the logic for mutating our state when a binding action is sent from the UI, and then our core Reduce reducer will run.

UUID 24:32

We can start by switching over the action so that we can figure out how we want to implement the logic and behavior for each case: Reduce { state, action in switch action { case .addAttendeeButtonTapped: <#code#> case .binding(_): <#code#> case let .deleteAttendees(atOffsets: indices): <#code#> } }

UUID 24:37

And we don’t have to do anything for the binding action in our core reducer: case .binding: return .none

UUID 24:45

The addAttendeeButtonTapped case is quite straightforward. We will create a new attendee, append them to the array in the standups value, and further we will point the focus to that new attendee so that their text field is already highlighted: case .addAttendeeButtonTapped: let attendee = Attendee(id: Attendee.ID()) state.standup.attendees.append(attendee) state.focus = .attendee(attendee.id) return .none

UUID 25:27

So now we are finally seeing some real logic enter into our features.

UUID 25:35

The next easiest case to implement is deleteAttendees . It could be as easy as simply passing along the deleted indices to the remove(atOffsets:) method on arrays: case let .deleteAttendees(atOffsets: indices): state.standup.attendees.remove(atOffsets: indices) return .none

UUID 25:56

…and that certainly gets the job done, but we can do better.

UUID 26:00

First of all, it’d be nice to guarantee that the standup has at least one attendee, just as we did in the initializer. That logic is easy enough to layer on: if state.standup.attendees.isEmpty { state.standup.attendees.append( Attendee(id: Attendee.ID()) ) }

UUID 26:24

And finally, just to spice things up a bit more, after deleting the row let’s also re-focus to the attendee that was closest to the field we just deleted: guard let firstIndex = indices.first else { return .none } let index = min( firstIndex, state.standup.attendees.count - 1 ) state.focus = .attendee(state.standup.attendees[index].id) return .none

UUID 26:39

And now we are seeing some really nuanced logic in our feature. This is logic that is easy to get wrong, and we are even doing array subscripting which can crash if we get the index math wrong. So this is something we will definitely want to get test coverage on eventually, and thanks to the fact that we can use simple value types to model our domains, it is incredibly easy to do so. But we are going to get into that a bit later.

UUID 27:18

Before moving on, it’s worth mentioning that if we did want to listen for when certain bindings are changed, like say the standup’s title, we could use a reducer operator called onChange , which is similar to SwiftUI’s onChange view modifier: BindingReducer() .onChange(of: \.standup.title) { oldTitle, newTitle in // Special logic when the standup's title is changed }

UUID 27:58

But we don’t need any of that power now.

UUID 28:09

OK, and that is all there is to the logic and behavior of this feature. What’s interesting about what we have done so far is that we created some view hierarchy in order to get a feel for what the feature should look like, and then used that as inspiration for how to model our domain and implement the reducer. From that point we were able to work in the nice, understandable world of value types and functions. Everything is a value type, from state to actions, and the way we evolve state is just a function.

UUID 28:35

Compare this to vanilla SwiftUI, where typically your main currency type is ObservableObject , which is a reference type. This means any change made to your object can be instantly observed by anywhere else in the application, and worse, any other part of the app can make changes to your object without you knowing about it. This creates a lot of uncertainty in a code base, and we just don’t have to think those kinds of things when dealing with value types. The only way to change the state in a Composable Architecture feature is to send an action into the store. There is literally no other way.

UUID 29:06

Now let’s move onto the view. We already have a stub in place, so it should only be a matter of adding a store to the view, observing the store to get a view store, and then plugging in the various pieces of state and sending actions.

UUID 29:13

Let’s start by adding a store property to the view: struct StandupFormView: View { let store: StoreOf<StandupFormFeature> … }

UUID 29:23

That instantly creates a compiler error in our view, so let’s fix that by supplying a store: #Preview { NavigationStack { StandupFormView( store: Store(initialState: StandupFormFeature.State(standup: .mock)) { StandupFormFeature() } ) } }

UUID 29:41

Next we will observe the store by using the WithViewStore view, just like we did in the standups list view. But, unlike last time, this time we will observe all state in the feature because we need access to all state. Observing everything isn’t such a big deal here because this feature is at a leaf node of our application: var body: some View { WithViewStore(self.store, observe: { $0 }) { viewStore in … } }

UUID 29:59

However, in order to observe state in WithViewStore the state must be equatable, so let’s do that real quick: struct State: Equatable { … }

UUID 30:05

Now everything is compiling and we can start filling in the state and actions. The first bit of state is where we need to supply a binding for the title text field: TextField("Title", text: <#""#>)

UUID 30:13

But now we can replace that with a true binding by deriving a binding from our view store using a nice succinct syntax: TextField("Title", text: viewStore.$standup.title)

UUID 30:39

The same can be done with the duration slider: Slider( value: viewStore.$standup.duration, in: 5...30, step: 1 ) { … }

UUID 30:46

However, there is one additional transformation we need to make to this binding. We decided to use the nice, concise Duration type in our model, but the Slider component in SwiftUI only understands BinaryFloatingPoint . So, we need a way to convert back and forth from Duration to Double .

UUID 31:06

And luckily for us this is quite easy. We can just copy-and-paste this getter/setter computed property into the project: extension Duration { fileprivate var minutes: Double { get { Double(self.components.seconds / 60) } set { self = .seconds(newValue * 60) } } }

UUID 31:33

…and that instantly gives us access to a minutes property defined on Binding that can transform a binding of Duration into a Binding of Double : Slider( value: viewStore.$standup.duration.minutes, in: 5...30, step: 1 ) { … } That works thanks to the magic of dynamic member lookup, which allows you to access any property on a binding that is defined as a property on the underlying wrapped value.

UUID 31:46

The next bit of state to update is where we have a hard coded “5mins”, which is supposed to display a formatted string of the current duration. So, let’s do that: Text(viewStore.standup.duration.formatted(.units()))

UUID 31:58

The next bit of state to update is the theme picker, which can be done easily enough: ThemePicker(selection: viewStore.$standup.theme)

UUID 32:05

Even the ForEach for the attendees can be handled the same: ForEach(viewStore.$standup.attendees) { $attendee in TextField("Name", text: $attendee.name) } This is using the advanced feature of property wrappers that allows us to provide a binding of a collection to ForEach and get back a binding of each individual element. That binding is then passed along to the text field for the attendee’s name.

UUID 32:17

Next we have some actions to send. The first one is in the onDelete modifier that is invoked whenever the user performs a swipe+delete gesture on a row. We will just pass along the indices .onDelete { indices in viewStore.send(.deleteAttendees(atOffsets: indices)) }

UUID 32:29

And the second is when the “Add attendee” button is tapped: Button("Add attendee") { viewStore.send(.addAttendeeButtonTapped) }

UUID 32:53

So, that’s the basics of the view, but there is another part to consider, and that’s the focus. We have additional state in our feature to govern where the focus is in the UI, but we aren’t doing anything in the view to actually do that.

UUID 33:18

The one one does this is by using the focused(_:equals:) view modifier: TextField("Title", text: viewStore.$standup.title) .focused(<#FocusState<Hashable>.Binding#>, equals: <#Hashable#>)

UUID 33:26

That allows you to specify a binding to some hashable state, which is the thing that describes the current focus, as well as a hashable value that represents the focus of this text field. When the binding changes to a value matching the hashable value provided, the text field will be focused.

UUID 33:49

So, sounds straightforward, but sadly it is not. If you look closely you will notice that the focused modifier doesn’t take a regular binding. It takes a FocusState.Binding , which is something that can only be created from the @FocusState property wrapper, and that only works in views.

UUID 34:27

That’s right, focus in SwiftUI can only be controlled with constructs that work only in views. If you have complex and nuanced logic around how focus flows throughout your feature, then according to SwiftUI you must put all that logic in your view. You are not directly allowed to do that work in an observable object if you are using vanilla SwiftUI, or a reducer if you are using the Composable Architecture.

UUID 34:51

There is a way to work around this luckily, but you do have to do a bit of work. What we need to do is add some focus state to our view: struct StandupFormView: View { let store: StoreOf<StandupForm> @FocusState var focus: StandupFormFeature.State.Field? … }

UUID 35:06

It’s a bit of a bummer that we now have state duplicated across the view and feature domain, but sadly that’s just how it has to be.

UUID 35:13

But in the view we can leverage a tool called bind : Form { … } .bind(<#modelValue: _Bindable#>, to: <#_Bindable#>)

UUID 35:24

And it allows you to play changes back and forth between two “bindable” things, which includes things like focus state, @State , app store, scene storage and more. So we can bind our source of truth from the store back to the view’s focus state, and vice versa. .bind(viewStore.$focus, to: self.$focus)

UUID 36:05

It’s a bit of a pain, but it gets the job done. We can even hop over to the implementation to see all the code the bind operator helps us to avoid. This is all the logic we don’t have to write: let modelValue: ModelValue let viewValue: ViewValue @State var hasAppeared = false func body(content: Content) -> some View { content .onAppear { guard !self.hasAppeared else { return } self.hasAppeared = true guard self.viewValue.wrappedValue != self.modelValue.wrappedValue else { return } self.viewValue.wrappedValue = self.modelValue.wrappedValue } .onChange(of: self.modelValue.wrappedValue) { guard self.viewValue.wrappedValue != $0 else { return } self.viewValue.wrappedValue = $0 } .onChange(of: self.viewValue.wrappedValue) { guard self.modelValue.wrappedValue != $0 else { return } self.modelValue.wrappedValue = $0 } }

UUID 36:34

And we want to reiterate that this is a problem that plagues vanilla SwiftUI too. Not just the Composable Architecture.

UUID 36:48

If you have any logic whatsoever around focus in the feature, then that logic must be trapped in the view layer, and hence hard to test. For example, what if you had a login field with and email and password, and based on the response from an API request you wanted to switch the focus back to the email or password text field. Well, I hope you are OK with having all of that logic, including the API request, in the view, because that is what the @FocusState property wrapper demands.

UUID 37:11

And after all of that we can finally tell SwiftUI which focus state value corresponds to which text field: TextField("Title", text: viewStore.$standup.title) .focused(self.$focus, equals: .title) … TextField("Name", text: $attendee.name) .focused(self.$focus, equals: .attendee(attendee.id))

UUID 37:38

We can already see in the preview that it seems to work. We see that right out of the gate the title field is focused. I can move sliders around, choose themes, add and remove attendees. We even see that focus changes like we expect when adding and removing attendees.

UUID 38:03

And now finally our view is integrated with our Composable Architecture feature. And I think it’s pretty incredible how simple the view is right now, outside of the little focus snafu we ran into.

UUID 38:14

Its only purpose is to read state from the view store in order to get data into the UI, and blindly send actions to the view store. And this is one of the reasons why we like to name the cases in our Action enums precisely after what the user literally does in the view rather than what logic we plan on executing in the reducer. This means there is no room for interpretation of what to do in the view.

UUID 38:56

This means there is no logic whatsoever in the view, and that’s what makes features built in the Composable Architecture so easy to test.

UUID 39:05

And before moving on, we just want to mention that in the future, once you can target iOS 17 and later, you will be able to greatly simplify this view. Not only will you be able to get rid of the view store and read state and send actions directly to the store, but you will even be able to derive bindings directly from the store in a much simpler syntax: var body: some View { Form { Section { TextField("Title", text: $store.standup.title) .focused($focus, equals: .title) HStack { Slider( value: $store.standup.duration.minutes, in: 5...30, step: 1 ) { Text("Length") } Spacer() Text( store.standup.duration.formatted(.units()) ) } ThemePicker(selection: $store.standup.theme) } header: { Text("Standup Info") } Section { ForEach( $store.standup.attendees ) { $attendee in TextField("Name", text: $attendee.name) .focused( $focus, equals: .attendee(attendee.id) ) } .onDelete { indices in store.send( .deleteAttendees(atOffsets: indices) ) } Button("Add attendee") { store.send(.addAttendeeButtonTapped) } } header: { Text("Attendees") } } .bind($store.focus, to: $focus) }

UUID 40:00

At this point, this view does not look that much different than if it had been built in vanilla SwiftUI.

UUID 40:05

And in fact, we can compare them directly right now because during our “ Modern SwiftUI ” series a few months back we built the standups app using only the techniques of vanilla SwiftUI, and we then open sourced that project.

UUID 40:21

So, I am going to open that project, and paste the entire view into Kaleidoscope, which is a nice tool for looking at diffs of text documents, among other things.

UUID 40:27

And then I am going to paste the view from our current project into Kaleidoscope, and we will see something amazing.

UUID 41:04

The vanilla version of the view and the Composable Architecture version of the view are almost identical. We use a Store instead of a model, and we send actions instead of calling methods, but outside of that it’s basically all the same.

UUID 41:34

This means that Composable Architecture applications really only differ from vanilla SwiftUI applications by how they like to model their domains, using value types instead of reference types, and having a single entry point for evolving state forward when an action is received.

UUID 41:45

However, all of that is a future in which iOS 17 has been officially released, and when we’ve added support for the tools, and when you are able drop iOS 16, so probably not for awhile. So, let’s undo that for now. Testing the standup form

UUID 41:59

So, we now have another full feature built in the Composable Architecture, and this one quite a bit more interesting than the first one we built. It is managing some complex logic around adding and removing attendees, and controlling how the focus flows around the screen.

UUID 42:12

The next step could be to integrate this feature into the parent feature, which is the standups list feature. Because what we want is when you tap the “Add” button in the top-right of the main list screen a sheet comes flying up with the standup form we just built. Then you should be able to fill in all the details, hit “Save”, and the sheet should dismiss and the standup should be added to our list.

UUID 42:31

And that will be really fun to do, but before even getting to any of that, let’s write a quick unit test for this feature. As we saw previously in our counter feature, testing is one of the true super powers of the Composable Architecture.

UUID 42:43

Because all of our feature’s logic and behavior is encapsulated into a single unit, and because we are using value types for everything, the library has the ability to run your feature in a kind of sandbox environment so that it can monitor everything happening on the inside. This allows us to cook up a really amazing testing tool so that you can assert on everything happening in your feature.

UUID 43:03

Let’s write a quick test for some of the gnarly logic that has crept into our seemingly simple standup form.

UUID 43:10

I’ll create a new test file and paste in some basic scaffolding: import ComposableArchitecture import XCTest @testable import Standups @MainActor final class StandupFormTests: XCTestCase { }

UUID 43:22

There are a few different things we could test in this feature, and we highly encourage our viewers to write a full test suite, but we are just going to concentrate on one piece of gnarly logic in this feature, and that is adding and deleting an attendee.

UUID 43:34

Let’s create a test store for the StandupFormFeature reducer, but we will start the feature in a specific state with a standup that has one attendee: func testAddDeleteAttendee() async { let store = TestStore( initialState: StandupFormFeature.State( standup: Standup( id: UUID(), attendees: [Attendee(id: UUID())] ) ) ) { StandupFormFeature() } }

UUID 44:19

Then let’s emulate the user tapping “Add attendee” to add a new attendee to the standup: await store.send(.addAttendeeButtonTapped)

UUID 44:34

How do we expect state to change? Well, we expect a new attendee to be added to the array in the standup value: $0.standup.attendees.append(<#Attendee#>)

UUID 44:52

To do that we need to construct an attendee: $0.standup.attendees.append( Attendee(id: <#UUID#>) )

UUID 44:57

And to do that we need to provide an attendee ID, so I guess we will just create a fresh one: $0.standup.attendees.append( Attendee(id: Attendee.ID()) )

UUID 45:05

I would be very surprised if this test passes since the ID is going to be a randomly generated UUID, but let’s give it a shot and see what happens.

UUID 45:17

Well, we of course get a failure: A state change does not match expectation: … StandupFormFeature.State( − _focus: .title, + _focus: .attendee( + UUID(4F03B4FB-6D8E-4063-A8D7-BE1E5F2527DF) + ), _standup: Standup( id: UUID(41D49A25-04AC-4312-8080-3FACE5C0500D), attendees: [ [0]: Attendee(…), [1]: Attendee( − id: UUID(4DE38122-C8D1-45C9-853C-31CEA48E8CEB) + id: UUID(4F03B4FB-6D8E-4063-A8D7-BE1E5F2527DF) name: "" ) ], duration: 1 minute, meetings: [], theme: .bubblegum, title: "" ) ) (Expected: -, Actual: +)

UUID 45:27

Part of this failure is no surprise. The attendee we added doesn’t match the one added by the feature because the IDs are different. The other part of the failure is a surprise to me because I totally forgot that to assert on that behavior. This is the power of exhaustive testing that the Composable Architecture gives us by default. It forces us to assert on everything in the system so we don’t accidentally forget.

UUID 45:50

So, we need to further assert that the focus changes to an attendee, but again I have no idea how to predict the correct ID, so I will just generate a new one: await store.send(.addAttendeeButtonTapped) { $0.focus = .attendee(UUID()) $0.standup.attendees.append( Attendee(id: UUID()) ) }

UUID 46:06

Now when we run the test the failure is a little smaller: A state change does not match expectation: … StandupFormFeature.State( _focus: .attendee( − UUID(C35D41CB-C013-4808-9A7D-5249674D227C) + UUID(66B3A16D-ED41-4C89-B4FF-82F7CFA2E784) ), _standup: Standup( id: UUID(80A2176A-5C02-4D4D-9418-5BDCC22384E2), attendees: [ [0]: Attendee(…), [1]: Attendee( − id: UUID(BE5658CF-97E6-4BAA-8C96-CF0D2BAA8B23) + id: UUID(66B3A16D-ED41-4C89-B4FF-82F7CFA2E784) name: "" ) ], duration: 1 minute, meetings: [], theme: .bubblegum, title: "" ) ) (Expected: -, Actual: +)

UUID 46:11

But we still have a failure because we aren’t predicting the correct ID.

UUID 46:16

The reason this is happening is because we are secretly creating random UUIDs in both our feature and in the test. When we see something like this: case .addAttendeeButtonTapped: let attendee = Attendee(id: UUID()) …

UUID 46:36

…then we should know that we are creating a random UUID in a completely uncontrollable way. Every time we run our test this code is going to generate a new ID, making it impossible for us to predict, and thus wreaking havoc on our ability to exhaustively test this feature.

UUID 46:46

This is another one of those sneaky dependencies that gets its fangs into our code and wreaks havoc. But luckily for us our dependency management system comes with a tool to control this dependency, right out of the box.

UUID 46:58

We can declare that our feature has a dependency on a UUID generator by using the @Dependency property wrapper: @Dependency(\.uuid) var uuid

UUID 47:06

And then anywhere we find ourselves generating an ID out of then air we will use this dependency instead, such as in the reducer: let attendee = Attendee(id: self.uuid()) … state.standup.attendees.append(Attendee(id: self.uuid()))

UUID 47:23

Now we are in a position to actually control this dependency and predict what ID will be generated. But first, let’s just run the test to see what happens. We get a new kind of failure: testAddDeleteAttendee(): @Dependency(\.uuid) has no test implementation, but was accessed from a test context:

UUID 47:33

This lets us know that we are using the UUID dependency in this test but haven’t overridden it. So, let’s do that, and we will override it with what is known as the “incrementing” UUID generator: } withDependencies: { $0.uuid = .incrementing }

UUID 48:01

This is a generator that simply returns an incrementing UUID every time you call it.

UUID 48:07

If we run the test again we still get a test failure because we are generating a random UUID in the test, but the failure message now gives us a hint that we are in a much better situation: A state change does not match expectation: … StandupFormFeature.State( _focus: .attendee( − UUID(7A3DFCE4-5E16-455C-97D0-00F029A5D17B) + UUID(00000000-0000-0000-0000-000000000000) ), _standup: Standup( id: UUID(5A590807-AE5D-4AF3-8F44-4D282A52AD26), attendees: [ [0]: Attendee(…), [1]: Attendee( − id: UUID(11CA46BE-E212-4A4C-B267-93BBFB048476) + id: UUID(00000000-0000-0000-0000-000000000000) name: "" ) ], duration: 1 minute, meetings: [], theme: .bubblegum, title: "" ) ) (Expected: -, Actual: +)

UUID 48:16

The UUID generated from the feature is all zeroes, which means it is very predictable. So we can now make the proper assertion: await store.send(.addAttendeeButtonTapped) { $0.focus = .attendee(Attendee.ID(UUID(0))) $0.standup.attendees.append( Attendee(id: Attendee.ID(UUID(0))) ) }

UUID 48:41

…and this passes!

UUID 48:43

And now let’s also emulate the user deleting that new attendee, and remember that the focus changes too back to the original attendee: await store.send(.deleteAttendees(atOffsets: [1])) { $0.focus = .attendee($0.standup.attendees[0].id) $0.standup.attendees.remove(at: 1) }

UUID 49:25

And this test passes too!

UUID 49:29

So again it is amazing to see how the library has our back each step of the way, making sure we assert on everything happening in the feature. If there was something else changed in the state that we forgot about, we would immediately be met with a test failure letting us know there is more to assert on.

UUID 49:43

And this tool really is thanks to the fact that we are using value types for our domain. If we were using reference types this just would not be possible because we couldn’t capture a copy of state before running the action, and then compare to state after running the action. Next time: adding a standup

UUID 49:56

OK, so this is all looking great. There are probably a few more tests that could be written, but let’s start moving onto more exciting things. Brandon

UUID 50:02

Right now we have two features built in the Composable Architecture, the standups list and the standup form, and they are supposed to be intimately related. We are supposed to be able to present the form view from the list view in a sheet when the “Add” button is tapped, and then further facilitate communication between these features to add a standup to the root list.

UUID 50:22

Let’s start doing that work, and get our first peek into what navigation looks like in the Composable Architecture…next time! References Composable Architecture Brandon Williams & Stephen Celis • May 4, 2020 The Composable Architecture is a library for building applications in a consistent and understandable way, with composition, testing and ergonomics in mind. http://github.com/pointfreeco/swift-composable-architecture Getting started with Scrumdinger Apple Learn the essentials of iOS app development by building a fully functional app using SwiftUI. https://developer.apple.com/tutorials/app-dev-training/getting-started-with-scrumdinger Downloads Sample code 0244-tca-tour-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 .