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?
With SwiftUI and Combine popping up in more and more apps, managing state is becoming more important. The Composable Architecture (TCA) is a framework providing many useful tools. It helps to structure your app with understandable and predictable state changes.
TCA focuses on state management, composition and testing. It’s developed by Brandon Williams and Stephen Celis from Point-Free. They have numerous videos providing information about functional programming and Swift development.
In this tutorial, you’ll create an app that shows the latest public GitHub repositories of raywenderlich.com. Additionally, it presents user information about the GitHub account. You’ll learn about:
- State management and how it helps you create better apps.
- Developing and testing features in isolation.
- Managing dependencies and side effects in an understandable way.
- Which tools the Composable Architecture offers to help you structure your app.
This tutorial assumes you’re already familiar with SwiftUI and Combine so that you can dive right into the Composable Architecture.
Getting Started
Start by downloading the project materials using the Download Materials button at the top or bottom of this tutorial. The starter project RepoReporter already includes the Composable Architecture Swift package.
Build and run the starter project. It will look like this:
The user feature is already implemented, but the rest looks a little bit empty. In this tutorial, you’ll complete the repository feature using the Composable Architecture.
The repository feature provides information like description and number of stars and forks. It also allows you to mark your favorite repositories to revisit them later.
Exploring the Composable Architecture
TCA focuses on different aspects around developing apps of different size and complexity. It offers concepts to solve various problems, including:
- State management: Each app consists of some sort of state. TCA offers a concept to manage and share state.
- Composition: This enables you to develop smaller features in isolation and compose them together to form the whole app.
- Side effects: These are often hard to understand and test. TCA tries to change this by defining a way to handle them.
- Testing: This is always important and TCA makes it easy to accomplish.
- Ergonomics: A framework is available that provides a convenient API to implement all components.
Understanding the Components of TCA
An app built with TCA consists of five main components that help to model your app:
- State: Often, a collection of properties represents the state of an app or a feature spread over many classes. TCA places all relevant properties together in a single type.
- Actions: An enumeration including cases for all events that can occur in your app, e.g., when a user taps a button, when a timer fires or an API request returns.
- Environment: A type wrapping all dependencies of your app or feature. For example, these can be API clients with asynchronous methods.
- Reducer: A function that uses a given action to transform the current state to the next state.
- Store: A place your UI observes for changes and where you send actions. Based on these actions, it runs reducers.
You might be wondering why you would use the Composable Architecture. There are many advantages:
- The data flow through the different components is clearly defined and unidirectional. This makes it easy to follow and understand.
- The environment contains all dependencies. You can understand and manage connections to the outside world from one single place. It’s possible to switch a live environment with a development or test environment. This allows you to configure or mock your dependencies without much effort.
- By composing separate features together, each feature can be planned, built and tested on its own. Thus, using TCA can change the way you work on apps, allowing you to focus on one part of the app at a time and even run it in isolation.
- Only reducers transform the state by processing actions. Thus, testing a feature boils down to running the reducer with actions and comparing the resulting state with the expectation.
Using the Composable Architecture
That sounds like a lot to do, but don’t worry, the Composable Architecture is a framework that makes it easy to get started. :]
The framework is distributed via the Swift Package Manager. You can include it in your project by adding a new Swift Package. However, the starter project already includes this framework, so you can start right away.
Modeling States and Actions
You’ll start by adding a list of repositories to the first tab of the starter project. You’ll do this using TCA’s state and actions, good starting points to dip your toes into using TCA.
Open RepositoryFeature.swift, where you find RepositoryState
, RepositoryAction
, RepositoryEnvironment
and repositoryReducer
.
These are the components you need to create for a feature. The store is already provided by the framework, so no need to create a new one.
RepositoryState
defines the state of the repository feature in a single place. To show repositories and mark them as your favorite, you need two properties. Add these two inside RepositoryState
:
var repositories: [RepositoryModel] = []
var favoriteRepositories: [RepositoryModel] = []
As the property names suggest, you’ll store all repositories in these two arrays.
Next, you need to define which actions can happen inside the repositories tab. Add these to RepositoryAction
:
// 1
case onAppear
// 2
case dataLoaded(Result<[RepositoryModel], APIError>)
// 3
case favoriteButtonTapped(RepositoryModel)
Here’s a description of each action:
- RepoReporter loads new data when a user selects the Repositories tab and the view appears. Thus, there needs to be an action to send the API request.
- The second action represents the event that the API request finished, either with a list of repositories or an error.
- Finally, you need another action for when a user taps the Favorite button on a repository.
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: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.
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.
Transforming State With Reducers
Reducer
is a struct
containing a function with the signature:
(inout State, Action, Environment) -> Effect<Action, Never>
This means that a reducer has three parameters it operates on, representing:
- State
- Action
- Environment
The state is an inout
parameter because it’s modified by the reducer depending on the given action. The reducer uses the environment to access the dependencies it contains.
The return type means the reducer can produce an effect that’s processed next. When no further effect needs to be executed, the reducer returns Effect.none
.
Open RepositoryFeature.swift, which already contains an empty reducer returning an empty effect. Replace it with the following code:
// 1
let repositoryReducer = Reducer<
RepositoryState,
RepositoryAction,
RepositoryEnvironment>
{ state, action, environment in
switch action {
// 2
case .onAppear:
return environment.repositoryRequest(environment.decoder())
.receive(on: environment.mainQueue())
.catchToEffect()
.map(RepositoryAction.dataLoaded)
// 3
case .dataLoaded(let result):
switch result {
case .success(let repositories):
state.repositories = repositories
case .failure(let error):
break
}
return .none
// 4
case .favoriteButtonTapped(let repository):
if state.favoriteRepositories.contains(repository) {
state.favoriteRepositories.removeAll { $0 == repository }
} else {
state.favoriteRepositories.append(repository)
}
return .none
}
}
Here’s what’s happening:
-
repositoryReducer
works onRepositoryState
,RepositoryAction
andRepositoryEnvironment
. You have access to these inside the closure. - The function of a reducer depends on the given action. In the case of
onAppear
, RepoReporter performs an API request to load repositories. To do so, the reducer usesrepositoryRequest
from the environment, which produces a new effect. But a reducer needs to return an effect with the same action type it can operate on. Thus, you need to map the effect’s output toRepositoryAction
. - In the case of
dataLoaded
, the reducer extracts the received repositories and updates the state. Then the reducer returns.none
, because no further effect needs to be processed. - The last action to handle is
favoriteButtonTapped
, which toggles the favorite status of a repository. If the given repository wasn’t favorited, it’s added to the state’s list of favorite repositories, and vice-versa.
Now that everything is set up, it’s time to see the repository feature in action. Open RepositoryView.swift.
If not visible, enable SwiftUI’s preview by clicking Adjust Editor Options in the upper-right corner of Xcode and selecting Canvas. Click Live Preview. The preview will look like this:
The repository feature is now finished and functional on its own. The next task is to combine it with the user feature. Composing separate features in one app is a main strength of the Composable Architecture. ;]
Composing Features
It’s time to combine the existing user feature with the repository feature you’ve just completed.
Open RootFeature.swift located in the Root group. It contains the same structure as the repository feature, but this time for the whole app. Currently, it only uses the user feature. Your task is now to add the repository feature as well.
Sharing Dependencies With SystemEnvironment
repositoryReducer
uses DispatchQueue
and JSONDecoder
provided by RepositoryEnvironment
. userReducer
, which is declared in UserFeature.swift, also uses DispatchQueue
and JSONDecoder
. However, you don’t want to copy them and manage the same dependencies multiple times. You’ll explore a mechanism to share the same dependencies between separated features: SystemEnvironment
.
Open SystemEnvironment.swift from the Shared group. It contains a struct
called SystemEnvironment
that holds all shared dependencies. In this case, it has DispatchQueue
and JSONDecoder
. It may also wrap a sub-environment like RepositoryEnvironment
that contains feature-specific dependencies. In addition to this, two static methods, live(environment:)
and dev(environment:)
, create pre-configured SystemEnvironment
instances to use in a live app or while developing.
Now that you’ve learned about SystemEnvironment
, it’s time to use it. Go to RepositoryFeature.swift and remove mainQueue
, decoder
and dev
from RepositoryEnvironment
. This leaves only repositoryRequest
, which is specific to the repository feature.
Next, replace:
let repositoryReducer = Reducer<
RepositoryState,
RepositoryAction,
RepositoryEnvironment>
With:
let repositoryReducer = Reducer<
RepositoryState,
RepositoryAction,
SystemEnvironment<RepositoryEnvironment>>
This lets repositoryReducer
use the shared dependencies from SystemEnvironment
.
You just need to make one last change. Switch to RepositoryView.swift. Replace RepositoryListView
in RepositoryListView_Previews
with the following code:
RepositoryListView(
store: Store(
initialState: RepositoryState(),
reducer: repositoryReducer,
environment: .dev(
environment: RepositoryEnvironment(
repositoryRequest: dummyRepositoryEffect))))
Previously, you used RepositoryEnvironment
when creating the store. repositoryReducer
now works with SystemEnvironment
. Thus, you need to use it when initializing Store
instead.
Create a new SystemEnvironment
using dev(environment:)
and pass in RepositoryEnvironment
using dummyRepositoryEffect
instead of the live effect.
Build the project, and it will now compile without errors again. You won’t see any visible changes, but you’re now using the system environment’s dependencies in your repository feature.
Combining States and Actions
Next, it’s time to add all of the repository feature’s state and actions to the root state. The root feature is the main tab bar of your app. You’ll define the state and actions for this feature by combining the two features inside the app to create a single root feature.
Go back to RootFeature.swift. RootState
represents the state of the whole app by having a property of each feature state. Add the repository state right below userState
:
var repositoryState = RepositoryState()
Next, you’ll add RepositoryAction
to RootAction
. Similar to RootState
, RootAction
combines the actions of all separate features into one set of actions for the whole app. To include the repository feature actions, add them right below userAction(UserAction)
:
case repositoryAction(RepositoryAction)
In your app, the user can do two things on the root view: See repositories and see a user profile, so you define an action for each of those features.
Adding Views to the App
Open RootView.swift. RootView
is the main view of RepoReporter.
Two tabs with Color.clear
act as placeholders for the repository feature views. Replace the first Color.clear
with:
RepositoryListView(
store: store.scope(
state: \.repositoryState,
action: RootAction.repositoryAction))
This code initializes a new RepositoryListView
and passes in a store. scope
transforms the global store to a local store, so RepositoryListView
can focus on its local state and actions. It has no access to the global state or actions.
Next, replace the second Color.clear
with:
FavoritesListView(
store: store.scope(
state: \.repositoryState,
action: RootAction.repositoryAction))
This time, add FavoritesListView
as a tab. Again, scope
transforms global state and actions to local state and actions.
Composing Reducers
The final step is to add repositoryReducer
to rootReducer
. Switch back to RootFeature.swift.
But how can a reducer working on local state, actions and environment work on the larger, global state, actions and environment? TCA provides two methods to do so:
- combine: Creates a new reducer by combining many reducers. It executes each given reducer in the order they are listed.
-
pullback: Transforms a given reducer so it can work on global state, actions and environment. It uses three methods, which you need to pass to
pullback
.
combine
is already used to create rootReducer
. Thus, you can add repositoryReducer
right after userReducer
‘s closing parenthesis, separating them with a comma:
// 1
repositoryReducer.pullback(
// 2
state: \.repositoryState,
// 3
action: /RootAction.repositoryAction,
// 4
environment: { _ in
.live(
environment: RepositoryEnvironment(repositoryRequest: repositoryEffect))
})
Here you see how to use pullback
. Although it’s just a few lines, there’s a lot going on. Here’s a detailed look at what’s happening:
-
pullback
transformsrepositoryReducer
to work onRootState
,RootAction
andRootEnvironment
. -
repositoryReducer
works on the localRepositoryState
. You use a a key path to plug out the local state from the globalRootState
. - A case path makes the local
RepositoryAction
accessible from the globalRootAction
. Case paths come with TCA and are like key paths, but work on enumeration cases. You can learn more about them at Point-Free: Case Paths. - Finally, you create an environment
repositoryReducer
can use. You useSystemEnvironment.live(environment:)
to start with the live environment defined in SystemEnvironment.swift. It already providesmainQueue
anddecoder
. Additionally, you create a new instance ofRepositoryEnvironment
usingrepositoryEffect
. Then, you embed it in the live environment.
Build and run the app. The repository feature and the user feature are working together to form one app:
Testing Reducers
One goal of TCA is testability. The main components to test are reducers. Because they transform the current state to a new state given an action, that’s what you’ll write tests for.
You’ll write two types of tests. First, you’ll test reducers with an action that produces no further effect. These tests run the reducer with the given action and compare the resulting state with an expected outcome.
The second type verifies a reducer which produces an effect. These tests use a test scheduler to check the expected outcome of the effect.
Creating a TestStore
Open RepoReporterTests.swift, which already includes an effect providing a dummy repository.
The first step is to create TestStore
. In testFavoriteButtonTapped
, add the following code:
let store = TestStore(
// 1
initialState: RepositoryState(),
reducer: repositoryReducer,
// 2
environment: SystemEnvironment(
environment: RepositoryEnvironment(repositoryRequest: testRepositoryEffect),
mainQueue: { self.testScheduler.eraseToAnyScheduler() },
decoder: { JSONDecoder() }))
This is what’s happening:
- You pass in the state and reducer you want to test.
- You create a new environment containing the test effect and test scheduler.
Testing Reducers Without Effects
Once the store is set up, you can verify that the reducer handles the action favoriteButtonTapped
. To do so, add the following code under the declaration of store
:
guard let testRepo = testRepositories.first else {
fatalError("Error in test setup")
}
store.send(.favoriteButtonTapped(testRepo)) { state in
state.favoriteRepositories.append(testRepo)
}
This sends the action favoriteButtonTapped
to the test store containing a dummy repository. When calling send
on the test store, you need to define a closure. Inside the closure, you define a new state that needs to match the state after the test store runs the reducer.
You expect repositoryReducer
to add testRepo
to favoriteRepositories
. Thus, you need to provide a store containing this repository in the list of favorite repositories.
The test store will execute repositoryReducer
. Then, it’ll compare the resulting state with the state after executing the closure. If they’re the same, the test passes, otherwise, it fails.
Run the test suite by pressing Command-U.
You’ll see a green check indicating the test passed.
Testing Reducers With Effects
Next, test the action onAppear
. With the live environment, this action produces a new effect to download repositories. To change this, use the same test store as above and add it to testOnAppear
:
let store = TestStore(
initialState: RepositoryState(),
reducer: repositoryReducer,
environment: SystemEnvironment(
environment: RepositoryEnvironment(repositoryRequest: testRepositoryEffect),
mainQueue: { self.testScheduler.eraseToAnyScheduler() },
decoder: { JSONDecoder() }))
This uses the test effect instead of an API call, providing a dummy repository.
Below the declaration of store
, add the following code:
store.send(.onAppear)
testScheduler.advance()
store.receive(.dataLoaded(.success(testRepositories))) { state in
state.repositories = self.testRepositories
}
Here you send onAppear
to the test store. This produces a new effect and testScheduler
needs to process it. That’s why you call advance
on the scheduler, so that it can execute this effect.
Finally, receive
verifies the next action that’s sent to the store. In this case, the next action is dataLoaded
, triggered by the effect created by onAppear
.
Run the tests. Again, you’ll see a green check, proving the handling of onAppear
is correct.
Understanding Failing Tests
The Composable Architecture provides a useful tool to understand failing tests.
In testFavoriteButtonTapped
, at the closure of send
, remove:
state.favoriteRepositories.append(testRepo)
Rerun the test, and it fails, as expected.
Click the red failure indicator and inspect the error message:
Because you removed the expected state from send
, the resulting state doesn’t match the expected empty state. Instead, the actual result contains one repository.
This way, TCA helps you understand the difference between the expected and actual state at first glance.
Where to Go From Here?
You can download the completed version of the project using the Download Materials button at the top or bottom of this tutorial.
The ideas behind the TCA framework are very close to finite-state machines (FSMs) or finite-state automata (FSA) and the state design pattern. Although the Composable Architecture comes as a ready-to-use framework and you can adapt it without needing to learn many of the underlying mechanisms, the following materials can help you understand and put all the concepts together:
- Here is a good general overview of finite-state machines.
- Design patterns based on states is one of 23 design patterns documented by the Gang of Four. You can find a brief introduction on the State pattern webpage.
- Read The State Design Pattern vs. State Machine to see some practical aspects and differences.
Although FSM/FSA is a mathematical computation model, every time you have a switch
statement or a lot of if
statements in your code, it’s a good opportunity to simplify your code by using it. You can use either ready-to-use frameworks, like TCA, or your own code, which isn’t really difficult if you understand crucial base principles.
You can learn more about the framework at the GitHub page. TCA also comes with detailed documentation containing explanations and examples for each type and method used.
Another great resource to learn more about TCA and functional programming in general is the Point-Free website. Its videos provide a detailed tour of how the framework was created and how it works. Other videos present case studies and example apps built with TCA.
The Composable Architecture shares many ideas with Redux, a JavaScript library mainly used in React. To learn more about Redux and how to implement it without an additional framework, check out Getting a Redux Vibe Into SwiftUI.
We hope you enjoyed this tutorial. If you have any questions or comments, please join the forum discussion below!
All videos. All books.
One low price.
A Kodeco subscription is the best way to learn and master mobile development — plans start at just $19.99/month! Learn iOS, Swift, Android, Kotlin, Flutter and Dart development and unlock our massive catalog of 50+ books and 4,000+ videos.
Learn more