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.

Leave a rating/review
Download materials
Save for later
Share
You are currently viewing page 2 of 4 of this article. Click here to view the first page.

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.

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.

  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.

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:

  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.