Introduction
The Composable Architecture (TCA) is a SwiftUI architectural framework that resembles the widely used Redux in iOS development. Created by Point-Free, TCA aims to manage UI side effects and enhance state management.
TCA consists of three main components: State, Action, and Reducer. Together, these form the Store, which manipulates state variables through actions. Within the view, the Store is typically converted to a ViewStore. This conversion enables access to state variables for UI components, allows sending actions for state manipulations, and facilitates running side effects.
The Reducer returns a Reduce type that executes the Effect of actions defined in the Action enum. API calls are made asynchronously from Effect<Action>, and actions can be dispatched based on the API result.
To demonstrate how to make API calls using the TCA framework, we’ll create a sample SwiftUI project. In this project, we’ll explore how to utilize the Effect<Action> of the reducer.
Creating a Project
To create the project, use Xcode (version 15.0 or higher) and TCA version (0.59.0 to 2.0.0). You can download Xcode from the App Store or Apple’s official developer site (https://developer.apple.com/xcode/resources).
Launch Xcode and start a new project by going to File > New > Project. You’ll see a screen where you can choose a project template:

Choose the App template and name your project. For this tutorial, we’ll use APICallsWithTCA. Enter a unique organization identifier, which will serve as the Bundle Identifier.
Click “Next” and select a location to save your project. Xcode will open it automatically. In the left sidebar, choose your app to access project settings. Navigate to the “Package Dependencies” tab and click the plus icon to add the TCA dependency.

A window will open to add a package dependency. To add TCA, search for the package using Point-Free’s official Git repository link (https://github.com/pointfreeco/swift-composable-architecture.git) in the search bar. Select a package version between 0.59.0 and 2.0.0, then add the package to your project.
It takes a few seconds to verify the package. You’ll then be prompted to add it to the selected target. Once successfully added, you’ll see swift-composable-architecture listed under packages with your specified version.


Folder Structure
Point-Free recommends two standard approaches for organizing basic TCA views:
- Keep the reducer and view in a single Swift file.
- Separate them into two files within a SampleView folder:
- SampleFeature.swift for the reducer,
- SampleView.swift for the view

In this demonstration, we’ll keep the reducer and view in the same file. Point-Free recommends a code snippet for basic TCA views. You can add this snippet to Xcode and reuse it for other views. (Learn how to add code snippets to Xcode). Here’s the boilerplate for a TCA Basic View.
import ComposableArchitecture struct <#App#>Feature: Reducer { struct State: Equatable { init(){} } enum Action: Equatable { } var body: some ReducerOf { Reduce { state, action in switch action { } } } } struct <#App#>View: View { let store: StoreOf<<#App#>Feature> var body: some View { WithViewStore(self.store, observe: {$0}) { viewStore in <#code#> } } } #Preview { <#App#>View(store: Store(initialState: .init(), reducer: { <#App#>Feature() })) }
Set up UI and API models
Create a new Swift file named ProfileView.swift in your APICallsWithTCA project folder. This file will house the UI for displaying user information fetched from an API. We’ll use Reqres, a sample API that provides user data, including profile images. Add the TCA basic view snippet to this file, replacing App placeholders with Profile and the code placeholder with a simple “Hello World” text. Remember to include the SwiftUI import statement. Your file should now look like this:
import SwiftUI import ComposableArchitecture struct ProfileFeature: Reducer { struct State: Equatable { init(){} } enum Action: Equatable { } var body: some ReducerOf { Reduce { state, action in switch action { } } } } struct ProfileView: View { let store: StoreOf var body: some View { WithViewStore(self.store, observe: {$0}) { viewStore in Text("Hello World") } } } #Preview { ProfileView(store: Store(initialState: .init(), reducer: { ProfileFeature() })) } Next, we’ll create the API models to store the data for our UI. In the project folder, create a new Swift file called APIModels.swift and add these models:
import Foundation struct SingleUser: Decodable { var data: UserData } struct UserData: Decodable, Equatable { var id: Int var email: String var firstName: String var lastName: String var avatar: String var fullName: String { "\(firstName) \(lastName)" } var avatarURL: URL { URL(string: avatar)! } enum CodingKeys: String, CodingKey { case id case email case avatar case firstName = "first_name" case lastName = "last_name" } } enum UserError: String, Equatable, Error { case serverError = "Please check your internet connection and try again!!!" case invalidUser = "The User doesn't exist, refresh and Try Again!!!" case invalidUrl = "Internal Errror refresh and Try Again!!!" case internalError = "Something went wrong refresh and Try Again!!!" } In the API models, UserData and UserError must conform to Equatable. This allows us to use them in the state as Result<UserData, UserError>, representing the API call’s outcome in the reduce function. Next, we’ll create UI components to display user data from Reqres. We’ll also add navigation buttons to the profile view, enabling users to switch between previous and next users.
struct ProfileView: View { let store: StoreOf var body: some View { WithViewStore(self.store, observe: {$0}) { viewStore in Text("Hello World") } } func profileComponent(_ user: UserData) -> some View { VStack { Spacer() Text("Profile") .font(.title) .bold() AsyncImage(url: user.avatarURL,scale: 0.5){image in image.scaledToFit().clipShape(Circle()) } placeholder: { ProgressView().frame(width:250,height: 250) } Text("User Id: \(user.id)") .foregroundColor(.gray) Text(user.fullName) .font(.title2) .bold() Link(user.email, destination: URL(string: "mailto://\(user.email)")!) Spacer() } } @ToolbarContentBuilder func profileControls( _ viewStore: ViewStoreOf ) -> some ToolbarContent { ToolbarItem(placement: .navigationBarLeading) { Button { viewStore.send(.previousUserButtonTapped) } label: { Image(systemName: "arrow.left") Text("Previous") } .disabled(viewStore.errorMessage != nil) } ToolbarItem(placement: .status) { Button { viewStore.send(.refreshButtonTapped) } label: { Text("Refresh") Image(systemName: "arrow.clockwise") } } ToolbarItem(placement: .navigationBarTrailing) { Button { viewStore.send(.nextUserButtonTapped) } label: { Text("Next") Image(systemName: "arrow.right") } .disabled(viewStore.errorMessage != nil) } } } In the profileControls, we’re sending actions defined in the Action enum and accessing the errorMessage from the State struct. To resolve the current errors in the editor, let’s add the necessary state variables and actions to control the UI.
struct State: Equatable { var id: Int = 1 var errorMessage: String? var response: Result<UserData, ApiError>? } In the State model, we use these variables:
- id: Fetches a specific user and is passed in the API query.
- errorMessage: Shows API errors in the UI.
- response: Stores the API call’s success or failure result.
enum Action: Equatable { case nextUserButtonTapped case previousUserButtonTapped case refreshButtonTapped case fetchData case fetchResponse(Result<UserData, ApiError>) } In the Action enum, we define the following actions:
- nextUserButtonTapped: Fetches and displays the next user in the list.
- previousUserButtonTapped: Fetches and displays the previous user in the list.
- refreshButtonTapped: Resets the user ID and fetches data again, useful in case of network failures.
- fetchData: Initiates an API call to retrieve the UserData.
- fetchResponse: Handles the API response from the fetchData action.
Next, we’ll implement the reduce function to manipulate state variables based on the profile control actions. Since Action is an enum, we’ll use a switch statement to handle each action. For all button controls, we need to fetch API data after state variable changes. To achieve this, we’ll send the fetchData action in the effect of each action. To prevent API calls when an error occurs, we’ll guard the fetchData action in the next and previous button cases.
Reduce { state, action in switch action { case .nextUserButtonTapped: guard state.errorMessage == nil else { // Guarding the fetching of data when error exist return .none } state.id += 1 return .send(.fetchData) case .previousUserButtonTapped: guard state.errorMessage == nil else { // Guarding the fetching of data when error exist return .none } state.id -= 1 return .send(.fetchData) case .refreshButtonTapped: state.id = 1 return .send(.fetchData) case .fetchData: return .none case .fetchResponse: return .none } } With all these changes in place, the reducer now looks like this:
struct ProfileFeature: Reducer { struct State: Equatable { var id: Int = 1 var errorMessage: String? var response: Result<UserData, UserError>? } enum Action: Equatable { case nextUserButtonTapped case previousUserButtonTapped case refreshButtonTapped case fetchData case fetchResponse(Result<UserData, UserError>) } var body: some ReducerOf { Reduce { state, action in switch action { case .nextUserButtonTapped: guard state.errorMessage == nil else { return .none } state.id += 1 return .send(.fetchData) case .previousUserButtonTapped: guard state.errorMessage == nil else { return .none } state.id -= 1 return .send(.fetchData) case .refreshButtonTapped: state.id = 1 return .send(.fetchData) case .fetchData: return .none case .fetchResponse: return .none } } } } Make the Network Call and API Config
With the profile view component and controls in place, let’s create the API call function. We’ll utilize this function in the fetchData effect to make network requests and handle the response in the fetchResponse action. To begin, we’ll set up an API configuration file that accepts a user id and returns UserData. Create a new Swift file called APIConfig.swift and implement the API call functionality.
import Foundation enum APIConfig { static func getUser(id: Int) async throws -> Result<UserData, UserError> { let baseUrl = "https://reqres.in/api/" let users = "users/" // Building the api url guard let userUrl = URL(string:"\(baseUrl)\(users)\(id)") else { return .failure(.invalidUrl) } do { let userRequest = URLRequest(url: userUrl) let (data, response) = try await URLSession.shared.data(for: userRequest) // Handling Error response with HTTP status code if let httpResponse = response as? HTTPURLResponse { switch httpResponse.statusCode { case 200: break case 404: return .failure(.invalidUser) case 500..<599: return .failure(.serverError) default: return .failure(.internalError) } } // Data decoding from json into object guard let usersData = try? JSONDecoder().decode(SingleUser.self, from: data) else { return .failure(.internalError) } // (Optional) Clearing cache and cookies from previous URLSession await URLSession.shared.reset() return .success(usersData.data) } catch { return .failure(.serverError) } } } Using this API configuration, we can make an API call in the fetchData action by passing the id to the getUser function. We then handle the API response using another action, sending fetchResponse via the send parameter provided by the .run effect. Note: The state is restricted in effect because it’s not allowed as a mutable inout parameter in concurrently executing code. This will throw an error like:
Mutable capture of the ‘inout’ parameter ‘state’ is not allowed in concurrently executing code.
To access state as a normal variable, we capture it as [state = state] in the run effect and pass the id to the API function. Before making the API call, we need to clear the previous error message and response. With these changes, fetchData looks like this:
case .fetchData: state.errorMessage = nil state.response = nil return .run { [state = state] send in let response = try await APIConfig.getUser(id: state.id) await send(.fetchResponse(response)) } Now we need to handle the success and failure responses of the API call in the fetchResponse action. On success, we’ll assign the user data to the response, which will be used to display the profile view. On failure, we’ll assign the error to response and set the error message in errorMessage state variable. Here’s how the fetchResponse action looks after these changes:
case .fetchResponse(.success(let userData)): state.response = .success(userData) return .none case .fetchResponse(.failure(let error)): state.response = .failure(error) state.errorMessage = error.rawValue return .none With all the changes, Reduce function looks like this:
Reduce { state, action in switch action { case .nextUserButtonTapped: guard state.errorMessage == nil else { return .none } state.id += 1 return .send(.fetchData) case .previousUserButtonTapped: guard state.errorMessage == nil else { return .none } state.id -= 1 return .send(.fetchData) case .refreshButtonTapped: state.id = 1 return .send(.fetchData) case .fetchData: state.errorMessage = nil state.response = nil return .run { [state = state] send in let response = try await APIConfig.getUser(id: state.id) await send(.fetchResponse(response)) } case .fetchResponse(.success(let userData)): state.response = .success(userData) return .none case .fetchResponse(.failure(let error)): state.response = .failure(error) state.errorMessage = error.rawValue return .none } } Handling the API Response in UI
API calls often take time to fetch data, depending on network conditions. To keep users informed, we can display a loading view as a placeholder. Once the API call retrieves the data, it’s assigned to the result and displayed in the profile component. We use UserData for the success state and show an error message for failure. While the response is nil, we display a loading view. By switching on the result, we efficiently manage the loading, failure, and success states of the API call. To kick off the initial API call, we leverage the .task modifier in the parent stack. This nifty modifier runs asynchronous code, enabling us to fire off the fetchData action.
VStack { switch viewStore.response { case .success(let user): self.profileComponent(user) case .failure: Text(viewStore.errorMessage ?? "") case nil: ProgressView("Loading") .progressViewStyle(.circular) .tint(.accentColor) .scaleEffect(1.3) } } .padding(.all) .task { viewStore.send(.fetchData) } By adding the profileControls to the toolbar of the parent stack, we can see the finalized version of the profile view UI. Replace the body in the view with the code snippet below:
var body: some View { WithViewStore(self.store, observe: {$0}) { viewStore in VStack { switch viewStore.response { case .success(let user): self.profileComponent(user) case .failure: Text(viewStore.errorMessage ?? "") case nil: ProgressView("Loading") .progressViewStyle(.circular) .tint(.accentColor) .scaleEffect(1.3) } } .padding(.all) .task { viewStore.send(.fetchData) } .toolbar { self.profileControls(viewStore) } } } Replace the main app with a profile view and wrap it in NavigationView:
import SwiftUI @main struct APICallsWithTCAApp: App { var body: some Scene { WindowGroup { NavigationView { ProfileView(store: .init(initialState: .init(), reducer: { ProfileFeature() })) } } } } Run the app in the simulator and test it to ensure all UI screens function correctly.
The profile view screens will look like this:


Conclusion
Integrating API calls in SwiftUI using The Composable Architecture (TCA) brings clarity, testability, and scalability to your codebase. By separating state, actions, and side effects, TCA allows you to manage asynchronous logic in a clean and maintainable way. Whether you’re building a simple app or a complex feature set, TCA provides a consistent structure that supports long-term growth and robustness in your SwiftUI projects.
Check out the GitHub repository for further reference.
