Getting Started With The Composable Architecture
Learn how to structure your iOS app with understandable and predictable state changes using Point-Free’s The Composable Architecture (TCA) framework. By David Piper.
Sign up/Sign in
With a free Kodeco account you can download source code, track your progress, bookmark, personalise your learner profile and more!
Create accountAlready a member of Kodeco? Sign in
Sign up/Sign in
With a free Kodeco account you can download source code, track your progress, bookmark, personalise your learner profile and more!
Create accountAlready a member of Kodeco? Sign in
Sign up/Sign in
With a free Kodeco account you can download source code, track your progress, bookmark, personalise your learner profile and more!
Create accountAlready a member of Kodeco? Sign in
Contents
Getting Started With The Composable Architecture
30 mins
- Getting Started
- Exploring the Composable Architecture
- Understanding the Components of TCA
- Using the Composable Architecture
- Modeling States and Actions
- Accessing the Store From Views
- Sending Actions to the Store
- Handling Side Effects
- Managing Dependencies With an Environment
- Transforming State With Reducers
- Composing Features
- Sharing Dependencies With SystemEnvironment
- Combining States and Actions
- Adding Views to the App
- Composing Reducers
- Testing Reducers
- Creating a TestStore
- Testing Reducers Without Effects
- Testing Reducers With Effects
- Understanding Failing Tests
- Where to Go From Here?
Accessing the Store From Views
Your view doesn’t interact with reducers or state directly. Instead, it gets its data from the store and passes actions to it. The store then executes reducers with the given action. These reducers have access to the state and update it depending on the given action, which triggers an update of the view. You can see this process here:
The first step is to give a view access to the store. Open RepositoryView.swift. RepositoryView
is the view that represents one single repository. In this view, hold down Command and click VStack. In the menu, choose Embed… to wrap it in a container. Next, replace
Container {
with
WithViewStore(self.store) { viewStore in
This is a SwiftUI view that wraps other views, similar to GeometryReader
. You can now access the store via viewStore
, read data from the state contained within and send actions to it. Whenever the store changes, the view will update automatically.
Next, in the same way as above, embed the ScrollView
in RepositoryListView
in a container and then replace
Container {
with
WithViewStore(self.store) { viewStore in
as you did before.
Finally, repeat the same steps for the ScrollView
in FavoritesListView
.
Right now, you’ll get errors, but you’ll fix them with the following steps.
In RepositoryView
, replace the two properties at the top of the structure with the following:
This makes sure the view is initialized with a reference to the store where all repositories are kept.
In RepositoryListView
and FavoritesListView
, replace both repositories
and favoriteRepositories
with:
Previously, you passed repositories
and favoriteRepositories
to the views. But since they’re already part of the state, and thus accessible through the store, the views don’t need them anymore.
And exchange it with:
Similarly, in FavoriteListView
, replace
with
In RepositoryView
, find:
And replace it with:
Since you changed RepositoryView
‘s properties, this also changes its initializer. In both RepositoryListView
and FavoritesListView
, find:
And replace it with:
Next, update RepositoryListView_Previews
. Replace the content of its previews
property with the following code:
Then, delete the dummyRepo
declaration.
This creates a new Store
with an initial RepositoryState
, repositoryReducer
and an empty RepositoryEnvironment
. Don’t worry, you’ll work on the reducer and environment later.
-
In
RepositoryView
, replace the two properties at the top of the structure with the following:let store: Store<RepositoryState, RepositoryAction> let repository: RepositoryModel
This makes sure the view is initialized with a reference to the store where all repositories are kept.
In
RepositoryListView
andFavoritesListView
, replace bothrepositories
andfavoriteRepositories
with:let store: Store<RepositoryState, RepositoryAction>
Previously, you passed
repositories
andfavoriteRepositories
to the views. But since they’re already part of the state, and thus accessible through the store, the views don’t need them anymore. -
Next, you’ll fix all places that used the properties you just replaced. In
RepositoryListView
, find:ForEach(repositories) { repository in
And exchange it with:
ForEach(viewStore.repositories) { repository in
Similarly, in
FavoriteListView
, replaceForEach(favoriteRepositories) { repository in
with
ForEach(viewStore.favoriteRepositories) { repository in
In
RepositoryView
, find:if favoriteRepositories.contains(repository) {
And replace it with:
if viewStore.favoriteRepositories.contains(repository) {
-
Since you changed
RepositoryView
‘s properties, this also changes its initializer. In bothRepositoryListView
andFavoritesListView
, find:RepositoryView(repository: repository, favoriteRepositories: [])
And replace it with:
RepositoryView(store: store, repository: repository)
Next, update
RepositoryListView_Previews
. Replace the content of itspreviews
property with the following code:RepositoryListView( store: Store( initialState: RepositoryState(), reducer: repositoryReducer, environment: RepositoryEnvironment()))
Then, delete the
dummyRepo
declaration.This creates a new
Store
with an initialRepositoryState
,repositoryReducer
and an emptyRepositoryEnvironment
. Don’t worry, you’ll work on the reducer and environment later.
let store: Store<RepositoryState, RepositoryAction>
let repository: RepositoryModel
let store: Store<RepositoryState, RepositoryAction>
ForEach(repositories) { repository in
ForEach(viewStore.repositories) { repository in
ForEach(favoriteRepositories) { repository in
ForEach(viewStore.favoriteRepositories) { repository in
if favoriteRepositories.contains(repository) {
if viewStore.favoriteRepositories.contains(repository) {
RepositoryView(repository: repository, favoriteRepositories: [])
RepositoryView(store: store, repository: repository)
RepositoryListView(
store: Store(
initialState: RepositoryState(),
reducer: repositoryReducer,
environment: RepositoryEnvironment()))
Sending Actions to the Store
The store not only provides access to the state, but also accepts actions to handle events happening on your views. This includes tapping the Favorite button in RepositoryView
. Replace:
action: { return },
With:
action: { viewStore.send(.favoriteButtonTapped(repository)) },
After this change, when a user taps this button, it’ll send the favoriteButtonTapped
action to the store, which will run the reducer and update the store.
There’s one more action you need to send from your views: onAppear
.
In RepositoryListView
, add the onAppear
modifier to ScrollView
:
.onAppear {
viewStore.send(.onAppear)
}
Whenever the list appears, it’ll send an action to the store, triggering a refresh of the repository data.
Handling Side Effects
Reducers transform the current state based on actions. But rarely does an app consist only of internal actions the user can take. Thus, there needs to be some way to access the outside world, e.g., to perform API requests.
The mechanism TCA uses for asynchronous calls is Effect
. But these effects cover more than asynchronous calls. They also wrap all non-deterministic method calls inside them. For example, this also includes getting the current date or initializing a new UUID
.
You can think of Effect
as a wrapper around a Combine publisher with some additional helper methods.
Open RepositoryEffects.swift. Here you can find two effects ready for you to use. repositoryEffect(decoder:)
calls the GitHub API. Then it maps errors and data, and transforms the result to an effect with eraseToEffect
.
Another effect is dummyRepositoryEffect(decoder:)
. It provides three dummy repositories to use while developing or for the SwiftUI preview.
Managing Dependencies With an Environment
But how can a reducer use these effects? Besides the state and actions, a reducer also has access to an environment. This environment holds all dependencies the app has in the form of effects.
Go back to RepositoryFeature.swift. Add these dependencies to RepositoryEnvironment
:
// 1
var repositoryRequest: (JSONDecoder) -> Effect<[RepositoryModel], APIError>
// 2
var mainQueue: () -> AnySchedulerOf<DispatchQueue>
// 3
var decoder: () -> JSONDecoder
Here’s what’s happening:
- Accessing GitHub’s API is the only dependency to the outside world the repository feature has. This property is a closure that gets passed a
JSONDecoder
and produces anEffect
. This effect eventually provides either a list ofRepositoryModel
or anAPIError
in case the request fails. -
mainQueue
provides access to the main thread. -
decoder
provides access to aJSONDecoder
instance.
You can create different environments for developing, testing and production. You’ll add one for previewing the repository feature in the next step. To do so, add the following code inside RepositoryEnvironment
below decoder
:
static let dev = RepositoryEnvironment(
repositoryRequest: dummyRepositoryEffect,
mainQueue: { .main },
decoder: { JSONDecoder() })
This creates a new environment dev
. It uses dummyRepositoryEffect
, providing dummy data together with .main
scheduler and a default JSONDecoder
.
To use this environment in RepositoryListView_Previews
, located in RepositoryView.swift, find:
environment: RepositoryEnvironment()))
And replace it with:
environment: .dev))
Build and run the project to make sure everything still compiles.
You’ve done a lot of work so far, but you’re still greeted with a blank screen! You created an environment that provides access to ways to download the repositories. You also declared all the actions that can happen on the repository screen and created a store that’s used by all your views.
One key thing is missing: a reducer. There is nothing connecting your environment to the store, so you’re not yet downloading anything. You’ll take care of that next.