
Ever heard of the phrase "Code should be Composable".
Before getting into What is Composable Architecture? It is crucial to know the need for a Composable Architecture.
We might wonder What is Composable? This is what we get in Google.
“in which every component is pluggable, scalable, replaceable, and can be continuously improved through agile development to meet evolving business requirements.”
In simple words, the code should be broken down into components, and these components should be pluggable, scalable, and replaceable.
The major factor for making the code Composable is essential to break down the code into components.
How can we break the code into components?
Well, we can break the code into functions and different sub-functions.
Yes! This will work but to some extent. The problem with this is, that the code might not be fundamentally built to work as a component. Let's look at an example.
In UIKit, to create a table view, we have to override multiple functions to define the properties of the table and set constraints for the table. A simple table will have a boilerplate code of 50 lines!
let's take an example in SwiftUI, the same table can be achieved in
As we can see, the difference between UIKit and SwiftUI is, that in UIKit, we have to define how it should be done. In SwiftUI, we have to define what to be done. With this example, we can conclude that UIKit is of more imperative style programming and SwiftUI is of declarative style programming.
To break the code into components, it is important that the programming style be declarative.
Here comes the next question, is SwiftUI declarative programming?
It depends! it supports both imperative and declarative programming. And SwiftUI is a combination of imperative and declarative. To support declarative programming, SwiftUI started utilizing the Combine framework which makes code more declarative. But things start getting out of hand if we did not have a proper and structured way of writing code in a more declarative manner. which means we need an architecture to maintain pluggable, scalable, and replaceable.
That's why we need to look at Composable Architecture (TCA)
COMPOSABLE ARCHITECTURE (TCA), which is inspired by the Redux Architecture.
Store is the key for this architecture which comprises of:
Each view has a Store also known as ViewStore which is depicted below.
Figure 1
ViewStore for individual views is passed by its ancestor view.
Therefore, Root view has the Main store (App Store in the diagram) which contains stores of individual views.
struct View0State: Equatable { var view1State: View1State? = nil }
struct View1State: Equatable { var view2State: View2State? = nil }
Example 1
The above code snippet is an example where we are having a View0State and within it we have View1State and it goes on.
What are View0State and View1State in the code snippet?
State: This contains the required variables for the corresponding view. i.e., a struct where all variables along with the state of the next screen are stored in it. Let's modify the above example.
struct View1State: Equatable { var view2State: View2State? = nil var isLoading: Bool = false ... }
Example 2
In the above example, we can see that all variables corresponding to View1 is been stored in View1State.
In short, the app's entire memory usage will be stored in View0State.
The reason for having such kind of State is, that the state of the app (i.e., variables of single or multiple views) changes very frequently which makes it difficult to manage without this architecture.
We can also access the data of any child from the root. Let's take the previous example.
view1State.view2State.someVariable = true
Example 3
Here, we are accessing someVariable from view1State.
Action: A function that takes an action from the View. In this architecture, we perform all tasks under Action. For instance, let's take a button click
Button(action: {print("Hi")}){ Text("Hello World") }
Example 4
The above button is by default used in SwiftUI. Here, instead of writing print(“Hi“) in the action block, we have to create an action explicitly for this.
The next question will be, How do we create an action?
We use enumeration in the app to specify all individual actions. Let’s take an example.
enum View0Action { case buttonAction }
Example 5
Instead of writing the logic in the button, we just write the corresponding action of it. Here, instead of writing print(“Hi“), we can just write buttonAction.
Where do we write the corresponding logic for buttonAction?
Here comes the picture of Reducer.
Reducer: This is the place where the action gets executed.
let view0Reducer: Reducer<View0State, View0Action, View0Environment> = .init { state, action, environment in switch action { case .buttonAction: print("Hi") return .none } }
NOTE: Switch case should return Effect, if nothing to return, return .none
Example 6
As we can see, all the logic can be written in the corresponding action.
How can we call the action in View?
struct View0: View { let store: Store<View0State, View0Action> var body: some View { WithViewStore(store) { viewStore in Button(action: {viewStore.send(.buttonAction)}){ Text("Hello World") } } }
Example 7
Note that WithViewStore(store) { viewStore in } in Example 7 gives us the access to perform actions as well as get the variables from the corresponding state.
WithViewStore(store) expects some View which means we cannot access actions or variables using WithViewStore(store) without a view.
To access an action from viewStore, we can perform viewStore.send(the action)
How can we access the action and state without a view?
We can access the action and state without a view as well by
ViewStore(store).send(the action)
In example 7, we get to know how to call/trigger an action from the View. As we have seen Store is the one used to get the variables from State, get the action from Action, and perform logic by the Reducer.
Now, we got to have a basic idea of State, Action, and Reducer. It will be more meaningful to visualize the process with the help of a flowchart.
Figure 2
Let’s take the example of button action, action is triggered from the view, detects the action, sends it to the reducer, reducer changes the state(variables), and finally, it changes the view.
What is the Environment and Effect in the diagram?
As we have discussed earlier, all actions from the reducer should return Effect, Effect is nothing but a resultant from an action. Let’s take the example of view0Reducer (Example 6). For buttonAction, we have returned .none which is of type Effect<View0Action, Never>.
In short, all actions performed by the reducer return effect which in turn reflect the changes in the view.
But, here is the catch, all actions must return an effect which means this architecture does not have completion handlers.
That means we won’t be able to call API and get the response. To solve this issue, we have Environment.
Environment: It is Middleware that is generally injected into the Reducer. API Calls are performed in this block.
struct View0Client { var someID: () -> Effect<SomeResponse, Error> }
Example 8
struct view0Environment {
var view0Client: View0Client
var mainQueue: AnySchedulerOf<DispatchQueue>
var uuid: () -> UUID
}
Example 9
Let us consider an example where we have to call an API to get a string someID(), so we create a struct with the APIs needed to be called and declared as variables as shown in Example 8
extension View0Client { static let api = View0Client( someID: { let request = GraphQLRequest<SomeResponse>(apiName: "APINAME", document: document, variables: nil, responseType: response.self, decodePath: "path") return Amplify.API.query(request: request) .resultPublisher .tryMap { try $0.get() } .eraseToEffect() } ) }
Example 10
In Example 10, we have initialized the View0Client struct, since we use Amplify SDK to call API, and as we discussed in the previous example, we cannot use the completion handler and it expects a return value of type Effect. We are utilizing the Combine framework for calling a background task i.e., API call.
Let’s dive deep into this block.
return Amplify.API.query(request: request) .resultPublisher .tryMap { try $0.get() } .eraseToEffect()
Amplify.API.query(request: request) returns GraphQLOperation<ResponseType> which is just a GraphQLOperation which is not yet being performed. To perform the GraphQLOperation in a background thread, we have to publish it using Publisher as shown below.
return Amplify.API.query(request: request) .resultPublisher
Once we publish it, we get the result from the API and we get the return type of AnyPublisher<Result<SomeResponse, GraphQLResponseError<SomeResponse>>, APIError> where the result which has the response and error is wrapped with the publisher. We aim to get the response in the format of Effect, so we use a higher-order function i.e., tryMap.
return Amplify.API.query(request: request) .resultPublisher .tryMap { try $0.get() }
$0.get() returns Result<SomeResponse, GraphQLResponseError<SomeResponse>>, we have eliminated the publisher. Finally, we can convert the Result to Effect using .eraseToEffect()
Now, we can call this API from the reducer with the help of action using the environment as shown in Figure 2.
Let’s create an action to call the API by modifying Example 5.
enum view0Action { case buttonAction case someIdAPI }
Example 11
We can call this action into the reducer by modifying Example 6.
let view0Reducer: Reducer<View0State, View0Action, View0Environment> = .init { state, action, environment in switch action { case .buttonAction: print("Hi") return .none case .someIdAPI: environment.view0Client .someID() .receive(on: environment.mainQueue) .catchToEffect() } }
Example 12
In Example 12, we are calling the API from the client and we are getting the response as a Result and converting it into Effect.
Usually, we get the response as Success and failure responses and based on that we would further proceed. Similarly, in example 12, we get the response as an effect, but we are not utilizing the result. To utilize the response, we have to map the result to another action. To keep it simple, suppose we call the API in action1 and once we get the response, we map the response to action2 and perform the required action.
Firstly, let’s create an action in the View0Action
enum View0Action { case buttonAction case someIdAPI case responseAction(Swift.Result<ResponseType, Error>) }
NOTE: It is mandatory to associate the correct Result with the action that we are going to map.
Adding a few codes to example 12.
let view0Reducer: Reducer<View0State, View0Action, View0Environment> = .init { state, action, environment in switch action { case .buttonAction: print("Hi") return .none case .someIdAPI: environment.view0Client .someID() .receive(on: environment.mainQueue) .catchToEffect() .map(View0Action.responseAction) .cancellable(id: View0CancelId()) } }
As we can see, we have called the API, and mapped it to another action, here is responseAction.
We have to make sure that we are handling the success and failure response gracefully.
let view0Reducer: Reducer<View0State, View0Action, View0Environment> = .init { state, action, environment in switch action { ... case .responseAction(.success(let response)): // write business logic here return .none case .responseAction(.failure(let error)): // write business logic here return .none } }
We have almost completed the basics of Composable Architecture (TCA).
Reference:
https://github.com/pointfreeco/swift-composable-architecture
https://github.com/coletiv/coletiv-ios-swiftui-tca-example