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?
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.