Advanced Git, Second Edition

Git is key to great version control and collaboration on software projects.
Stop struggling with Git and spend more time on the stuff that matters!

Home iOS & Swift Tutorials

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.

4.8/5 10 Ratings

Version

  • Swift 5.5, iOS 15, Xcode 13

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.

Note: Ideas behind TCA framework are very close to finite-state machines (FSMs) or finite-state automata (FSA) and the state design pattern. You may find it useful to familiarize yourself with these concepts in conjunction with reading this tutorial — see the Where to Go From Here? section for some references.

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:

Start project of RepoReporter, all three screens

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:

  1. 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.
  2. The second action represents the event that the API request finished, either with a list of repositories or an error.
  3. 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:

Flow in TCA — The view sends an action to the store, which calls the reducer. This updates the state and triggers an update of the view.

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.

Note: Don’t miss the step above.

Right now, you’ll get errors, but you’ll fix them with the following steps.

  1. 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 and FavoritesListView, replace both repositories and favoriteRepositories with:

    let store: Store<RepositoryState, RepositoryAction>
    

    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.

  2. 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, replace

    ForEach(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) {
    
  3. Since you changed RepositoryView‘s properties, this also changes its initializer. In both RepositoryListView and FavoritesListView, find:

    RepositoryView(repository: repository, favoriteRepositories: [])
    

    And replace it with:

    RepositoryView(store: store, repository: repository)
    

    Next, update RepositoryListView_Previews. Replace the content of its previews 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 initial RepositoryState, repositoryReducer and an empty RepositoryEnvironment. 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:

  1. 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 an Effect. This effect eventually provides either a list of RepositoryModel or an APIError in case the request fails.
  2. mainQueue provides access to the main thread.
  3. decoder provides access to a JSONDecoder 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.

The composable architecture tutorial sample app without a reducer

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:

  1. repositoryReducer works on RepositoryState, RepositoryAction and RepositoryEnvironment. You have access to these inside the closure.
  2. 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 uses repositoryRequest 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 to RepositoryAction.
  3. 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.
  4. 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:

Preview of the repository feature build with TCA.

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:

  1. pullback transforms repositoryReducer to work on RootState, RootAction and RootEnvironment.
  2. repositoryReducer works on the local RepositoryState. You use a a key path to plug out the local state from the global RootState.
  3. A case path makes the local RepositoryAction accessible from the global RootAction. 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.
  4. Finally, you create an environment repositoryReducer can use. You use SystemEnvironment.live(environment:) to start with the live environment defined in SystemEnvironment.swift. It already provides mainQueue and decoder. Additionally, you create a new instance of RepositoryEnvironment using repositoryEffect. 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:

Final state of RepoReporter. The repository feature and the user feature are combined to one single 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:

  1. You pass in the state and reducer you want to test.
  2. 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.

The Composable Architecture app with tests showing as passing in Xcode

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:

An error message of a failed test with TCA shows a detailed explanation of expected and actual state.

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:

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!

Average Rating

4.8/5

Add a rating for this content

10 ratings

More like this

Contributors

Comments