Scroll Top

How to Make API Calls in SwiftUI with the TCA Framework 

Feature-Image

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: 

Xcode Project Template Menu
Xcode Project Template Menu

Choose theApp 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 
Optional Folder Structure
Optional Folder Structure

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 Appplaceholders with Profile and thecodeplaceholder 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, UserDataand 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 thefetchResponse 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.swiftand 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 sendparameter 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 theresponse, which will be used to display the profile view. On failure, we’ll assign the error to response and set the error message in errorMessagestate 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 .taskmodifier 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.  

Gangadhar Chakali

+ posts
Privacy Preferences
When you visit our website, it may store information through your browser from specific services, usually in form of cookies. Here you can change your privacy preferences. Please note that blocking some types of cookies may impact your experience on our website and the services we offer.