My take on The Composable Architecture
- Context: Typical iOS App Architecture
- The claim
- Experiments
- What I love
- What I don’t love
- Where should I go next?
- References
The Composable Architecture or TCA for short is a library to build application in a consistent and understandable manner, with composition, testing, and ergonomics in mind. This library is made by Point-Free to apply the concepts of functional programming in a practical way.
Context: Typical iOS App Architecture
In the world of iOS development, there’s a few 4 most commonly used patterns in architecting an iOS application. Most notably MVC, MVP, MVVM, and VIPER. The details of these architecture is beyond the scope of this article. All these architecture has their own advantages and drawbacks. For example, MVC is simple but misusing it will cause a Massive View Controller. VIPER provides a good testability but it’ll generate much boilerplate code. At the end of the day, architectural decisions are based on the combination of use case, complexity, and many other variables. So there’s no such thing as the silver bullet of iOS architecture.
The claim
As the new guy in the iOS architecture neighborhood, TCA claims to provide:
- Consistency: degree of firmness, density, viscosity, or resistance to movement or separation of constituent particles
- Composition: a product of mixing or combining various elements or ingredients
- Testing
- Ergonomics: a product of mixing or combining various elements or ingredients
In this blog, we’ll test whether TCA fulfills what it claims to do or not.
Experiments
We’ll create a song reader app that have the capability to add songs to favorites and see all the favorites we have. All the code snippet in this blog is taken from my Lagu Sion implementation.
Setting up
To install this library, generate a new iOS application in your Xcode and add TCA to your Swift Package Dependency. More info, head here.
Building the app
In this blog, I’ll build the app from the smallest component possible and work ourselves up and wire everything up.
Modeling song
Simple, we can make a struct that contains all the fields needed. In this case we need to generate the id and song
public struct Song: Equatable, Identifiable {
public let id: UUID
public let number: Int
public let title: String
public let verses: [Verse]
public let reff: Verse?
public let songBook: SongBook
public init(id: UUID, number: Int, title: String, verses: [Verse], reff: Verse? = nil, songBook: SongBook) {
self.id = id
self.number = number
self.title = title
self.verses = verses
self.reff = reff
self.songBook = songBook
}
public var prefix: String { songBook.prefix }
public var color: Color { songBook.color }
}
public struct SongViewState: Equatable, Identifiable {
public var id: UUID {
return song.id
}
public var song: Song
public var isFavorite: Bool
public init(song: Song, isFavorite: Bool) {
self.song = song
self.isFavorite = isFavorite
}
}
extension SongViewState {
var number: Int { song.number }
var title: String { song.title }
var verses: [Verse] { song.verses }
var reff: Verse? { song.reff }
var prefix: String { song.prefix }
var color: Color { song.color }
}
Modeling action to be made on Song View
Simple, just use Swift’s enum to enumerate all the possible action.
public enum SongAction: Equatable {
case heartTapped
case removeFromFavorites
case addToFavorites
}
Modeling what will happen when action appears
Here, we start to use TCA’s first feature: Reducer. TCA will receive all the action that the views will trigger and after that, the action will be processed through the reducer. In TCA, reducer is the place where logic happens. State mutations, logical operation, and action handling is happening in the reducer.
public let songReducer = Reducer<SongViewState, SongAction, SongEnvironment> { state, action, environment in
switch action {
case .heartTapped:
if !state.isFavorite {
return Effect(value: SongAction.addToFavorites)
} else {
return Effect(value: SongAction.removeFromFavorites)
}
case .addToFavorites, .removeFromFavorites:
return .none
}
}
Implement how the Song View will look like
Simple, we create a view just like other SwiftUI views, but at the end, we’ll wire up the view with the state through a concept called Store
private struct TitleView: View {
let title: String
var body: some View {
HStack {
Spacer()
Text(title)
.font(.system(size: 32, weight: .bold, design: .`default`))
.multilineTextAlignment(.center)
Spacer()
}
}
}
private struct VerseView: View {
let verse: Verse
var body: some View {
VStack(alignment: .center, spacing: 10) {
ForEach(0..<self.verse.contents.count) { (j) in
Text(self.verse.contents[j])
}
}
}
}
public struct SongView: View {
private let store: Store<SongViewState, SongAction>
private let enableFavoriteButton: Bool
public init(store: Store<SongViewState, SongAction>, enableFavoriteButton: Bool) {
self.store = store
self.enableFavoriteButton = enableFavoriteButton
}
public var body: some View {
WithViewStore(self.store) { viewStore in
ScrollView(.vertical, showsIndicators: true) {
VStack(alignment: .center, spacing: 10) {
Spacer()
TitleView(title: viewStore.title)
Spacer()
ForEach(0..<viewStore.verses.count) { i in
Text("\(i + 1)")
.font(.system(.headline))
VerseView(verse: viewStore.verses[i])
Unwrap(viewStore.reff) { reff in
Spacer()
VerseView(verse: reff)
}
Spacer()
}
Spacer()
}
}
.navigationBarTitle(Text("\(viewStore.prefix) no. \(viewStore.number)"))
.navigationBarItems(
trailing: Button(action: { viewStore.send(.heartTapped) }) {
Image(systemName: viewStore.isFavorite ? "heart.fill" : "heart")
}
.disabled(!self.enableFavoriteButton)
)
}
}
}
Previews
Since we haven’t wire up the view to any entry point, we can use SwiftUI previews to see how our view implementation is going.
internal struct SongView_Previews: PreviewProvider {
static var previews: some View {
SongView(
store: Store(
initialState: SongViewState(
song: Song(
id: UUID(),
number: 1,
title: "Di Hadapan Hadirat-Mu",
verses: [
Verse(contents: [
"Di hadapan hadirat-Mu",
"Kami umat-Mu menyembah",
"Mengakui Engkau Tuhan",
"Allah kekal, Maha kuasa"
]),
Verse(contents: [
"Dari debu dan tanahlah",
"kita dijadikan Tuhan",
"Dan bila tersesat kita",
"Tuhan tak akan tinggalkan",
]),
Verse(contents: [
"Kuasa serta kasih Allah",
"Memenuhi seg’nap dunia",
"Tetap teguhlah firman-Nya",
"Hingga penuh hadirat-Nya",
]),
Verse(contents: [
"Di pintu Surga yang suci",
"menyanyi beribu lidah",
"Pada Tuhan kita puji",
"Sekarang dan selamanya",
])
], songBook: .laguSion
),
isFavorite: false
),
reducer: songReducer,
environment: SongEnvironment()),
enableFavoriteButton: true
)
}
}
Testing the implementation
class SongTests: XCTestCase {
func testHeartTapped_WithIsFavorite_False() {
let store = TestStore(
initialState: SongViewState(
song: Song(id: UUID(), number: 0, title: "", verses: [], songBook: .laguSion),
isFavorite: false
),
reducer: songReducer,
environment: SongEnvironment()
)
store.assert(
.send(.heartTapped),
.receive(.addToFavorites)
)
}
func testHeartTapped_WithIsFavorite_True() {
let store = TestStore(
initialState: SongViewState(
song: Song(id: UUID(), number: 0, title: "", verses: [], songBook: .laguSion),
isFavorite: true
),
reducer: songReducer,
environment: SongEnvironment()
)
store.assert(
.send(.heartTapped),
.receive(.removeFromFavorites)
)
}
}
For now, let’s take a break from coding and look back what we have made here. First, we have modeled the state of the Song View and actions. Next the reducer and the view. TCA provides us with a framework to pass triggers from view to the reducer through Store
. With store, developer can make SwiftUI previews easily. This checks the first claim that TCA is ergonomic.
Second, testing. Store
concept also has it’s corresponding ergonomic component in testing named TestStore
. This component enable us to intercept the actions triggered, sequence of actions, or even checks the state after every action sent or received. This checks the second claim that TCA is designed with testing in mind.
Cool, so far we have covered how TCA is ergonomic and designed with testing in mind. Let’s go to the next part of the implementation to prove the rest TCA claims.
Modeling Main List View
In the list view state, we store two lists. The list of songs, and favorite songs.
public struct MainState: Equatable {
public var songs: [Song]
public var favoriteSongs: [Song]
public var selectedBook: BookSelection
public var searchQuery: String
public var selectedSortOption: SortOptions
public var actionSheet: ActionSheetState<MainAction>?
public var alert: AlertState<MainAction>?
public init(
songs: [Song] = [],
favoriteSongs: [Song] = [],
selectedBook: BookSelection = .all,
searchQuery: String = "",
selectedSortOptions: SortOptions = .number,
actionSheet: ActionSheetState<MainAction>? = nil,
alert: AlertState<MainAction>? = nil
) {
self.songs = songs
self.favoriteSongs = favoriteSongs
self.selectedBook = selectedBook
self.searchQuery = searchQuery
self.selectedSortOption = selectedSortOptions
self.actionSheet = actionSheet
self.alert = alert
}
}
extension MainState {
var currentSongs: [SongViewState] {
get {
var result: [Song] = []
switch selectedBook {
case .all:
result = songs
case .songBook(let songBook):
result = songs(for: songBook)
}
return result.map { song -> SongViewState in
SongViewState(song: song, isFavorite: favoriteSongs.contains(song))
}
}
set {
}
}
func songs(for songBook: SongBook) -> [Song] {
return songs.filter { song in
song.songBook == songBook
}
}
}
Modeling the Main List View Actions
Here, we’re going to use our previously made songReducer
for each song we have in the main list.
public enum MainAction: Equatable {
case actionSheetDismissed
case alertDismissed
case appear
case error(LaguSionError)
case getSongs
case setSongs([Song])
case saveSearchQuery(String)
case searchQueryChanged(String)
case song(index: Int, action: SongAction)
case songBookPicked(BookSelection)
case sortOptionChanged(SortOptions)
case sortOptionTapped
case updateFavoriteSongs(newFavorites: [Song])
case noOp
}
public let mainReducer: Reducer<MainState, MainAction, MainEnvironment> = .combine(
songReducer.forEach(
state: \MainState.currentSongs,
action: /MainAction.song(index:action:),
environment: { _ in SongEnvironment() }
),
// ...
)
Let’s take a break and see what we did. First, we model the MainState
just as we did on SongViewState
. After that, we create the model of MainAction
and mainReducer
. This shows that building component in TCA is consistent. Every layer of the implementation follows the same consistent pattern. This checks the consistency claim.
Next, TCA also gave us the tools we need to reuse and compose the songReducer
in the mainReducer
. As we know, the main state, composes song state directly or indirectly. If this happens, we can always compose any state with many other smaller states and we can still have the visibility of anything that happens in the child component. This checks the composability claim.
Last experiment I’d like to show is to fast forward on how we’re going to wire multiple sibling states to the root state.
Assume we have implemented the Settings
and Favorites
section.
Model App State
To wire up all the sibling states to single state, we have to define how are the AppState
behave when there’s a change in AppState
or if there’s any changes in the child states.
struct AppState: Equatable {
var songs: [Song] = []
var favoriteSongs: [Song] = []
var selectedBook: BookSelection = .all
var selectedSortOptions: SortOptions = .number
var searchQuery: String = ""
var mainActionSheet: ActionSheetState<MainAction>? = nil
var mainAlert: AlertState<MainAction>? = nil
var isAvailableOffline: Bool = false
var fontSelection: FontSelection = .normal
var contentSizeSelection: ContentSizeSelection = .normal
}
extension AppState {
var main: MainState {
get {
MainState(
songs: self.songs,
favoriteSongs: self.favoriteSongs,
selectedBook: self.selectedBook,
searchQuery: self.searchQuery,
selectedSortOptions: self.selectedSortOptions,
actionSheet: self.mainActionSheet,
alert: self.mainAlert
)
}
set {
self.songs = newValue.songs
self.favoriteSongs = newValue.favoriteSongs
self.selectedBook = newValue.selectedBook
self.searchQuery = newValue.searchQuery
self.selectedSortOptions = newValue.selectedSortOption
self.mainActionSheet = newValue.actionSheet
self.mainAlert = newValue.alert
}
}
var favorites: FavoritesState {
get {
FavoritesState(songs: self.songs, favoriteSongs: self.favoriteSongs)
}
set {
self.songs = newValue.songs
self.favoriteSongs = newValue.favoriteSongs
}
}
var settings: SettingsState {
get {
SettingsState(
isAvailableOffline: self.isAvailableOffline,
fontSelection: self.fontSelection,
contentSizeSelection: self.contentSizeSelection
)
}
set {
self.isAvailableOffline = newValue.isAvailableOffline
self.fontSelection = newValue.fontSelection
self.contentSizeSelection = newValue.contentSizeSelection
}
}
}
Model Action and Reducer
enum AppAction {
case main(MainAction)
case favorites(FavoritesAction)
case settings(SettingsAction)
}
let appReducer: Reducer<AppState, AppAction, AppEnvironment> = .combine(
mainReducer.pullback(
state: \AppState.main,
action: /AppAction.main,
environment: { env in
MainEnvironment(
mainQueue: env.mainQueue,
laguSionDataSource: env.laguSionDataSource
)
}
),
favoritesReducer.pullback(
state: \AppState.favorites,
action: /AppAction.favorites,
environment: { _ in FavoritesEnvironment() }
),
settingsReducer.pullback(
state: \AppState.settings,
action: /AppAction.settings,
environment: { _ in SettingsEnvironment() }
)
)
Let’s stop and review again. In this section, we made the state, actions, and reducer. But if we look closely, appReducer
is doing nothing but composing all the child reducers and passing its respective states and actions using pullback
. This ultimately checks again the composability claim of TCA.
What I love
As we can see from the experiment, TCA delivers what it promised. Consistency, Composability, Testing, and Ergonomic. Personally, my favorite thing in TCA is how we can build mini components and wire it up to the chain until it has reaches the root/single source of truth. This ensures data consistency throughout screen and states.
What I don’t love
Right now, there’s no valid proof that this architecture is used in a huge app with high complexity. So we still have no idea on the extent of this architecture.
Where should I go next?
If you’re interested in how this library is designed, go to Composable Architecture series. If you’re into functional programming, especially in Swift, check out Point-Free’s site. They cover a lot of interesting ideas of functional programming specifically in Swift. If need more example of a working app using TCA, visit TCA’s GitHub examples.