MangoByte MangoByte
 ·  Essay

Refactoring Bitcoin Price Tracker to The Composable Architecture (TCA)

In our previous article, we built a live Bitcoin price tracker using SwiftUI and WebSocket. Today, we’re taking it a step further by refactoring our application to use The Composable Architecture (TCA).

This refactoring will serve as an opportunity to understand the core concepts of TCA.

Final result of our refactoring

Final result of our refactoring

The Composable Architecture, developed by Point-Free, is a library for building applications in a consistent and understandable way. It emphasizes composition, testing, and ergonomics.

Before we dive into the refactoring, let’s explore the core concepts of TCA: Reducer, Store, and how they work together.

We used the WebSocket case study provided by Point Free and refactored it for our use case. you view the original example here.

Core Concepts of TCA

Reducer

A Reducer is the heart of TCA. It’s responsible for describing how the state of your application changes in response to actions. In Swift, a Reducer is typically defined as a struct conforming to the Reducerprotocol. It has three main components:

  1. State: A type representing the entire state of your feature or application.
  2. Action: An enum representing all the actions that can occur in your feature.
  3. Reduce method: A function that takes the current state and an action, and returns a new state and any effects to run.

Here’s a basic structure of a Reducer:

@Reducer
struct Feature {
  @ObservableState
  struct State: Equatable { /* ... */ }
  enum Action { /* ... */ }

  var body: some Reducer<State, Action> {
    Reduce { state, action in
      switch action {
      //handle actions and state mutations
      }
    }
  }
}

Store

A Store is the runtime that actually runs your reducer and holds onto the current state of your application. It’s responsible for:

  1. Holding the current state of your application.
  2. Exposing a way to send actions to the reducer.
  3. Exposing a way to observe state changes.

Here’s how you typically create a store:

let store = Store(initialState: MyFeature.State()) {
    MyFeature()
}

How a Store Runs a Reducer

Image credit: Author — Duy Bui

Image credit: Author — Duy Bui

When you create a Store with a Reducer, here’s what happens:

  1. The Store initializes with the provided initial state.
  2. When an action is sent to the Store (usually from the UI), the Store calls the Reducer’s body property with the current state and the action.
  3. The Reducer returns a new state and any effects to run.
  4. The Store updates its internal state with the new state returned by the Reducer.
  5. If the Reducer returned any effects, the Store runs these effects.
  6. When the effects complete, they may send new actions back to the Store, repeating the cycle.

This cycle ensures that all state changes and side effects are handled in a predictable, centralized manner.

Refactoring the Bitcoin Price Tracker

Now, let’s see how these concepts apply to our Bitcoin price tracker. We’ll go through the refactoring process step by step:

Prerequisites

First, install the library by adding it as a package using SPM in Xcode.

Copy and paste the code below, these are wrappers and dependency configurations to help us interact with the WebSocket connection. we will explain that patrt in a another article.

For now, you just need to know that Dependencies in TCA are like Environment in SwiftUI.

@DependencyClient
struct WebSocketClient {
    struct ID: Hashable, @unchecked Sendable {
        let rawValue: AnyHashable
        
        init<RawValue: Hashable & Sendable>(_ rawValue: RawValue) {
            self.rawValue = rawValue
        }
        
        init() {
            struct RawValue: Hashable, Sendable {}
            self.rawValue = RawValue()
        }
    }
    
    enum Action {
        case didOpen(protocol: String?)
        case didClose(code: URLSessionWebSocketTask.CloseCode, reason: Data?)
    }
    
    enum Message: Equatable {
        struct Unknown: Error {}
        
        case data(Data)
        case string(String)
        
        init(_ message: URLSessionWebSocketTask.Message) throws {
            switch message {
                case let .data(data): self = .data(data)
                case let .string(string): self = .string(string)
                @unknown default: throw Unknown()
            }
        }
    }
    
    var open: @Sendable (_ id: ID, _ url: URL, _ protocols: [String]) async -> AsyncStream<Action> = {
        _, _, _ in .finished
    }
    var receive: @Sendable (_ id: ID) async throws -> AsyncStream<Result<Message, Error>>
    var send: @Sendable (_ id: ID, _ message: URLSessionWebSocketTask.Message) async throws -> Void
    var sendPing: @Sendable (_ id: ID) async throws -> Void
}

extension WebSocketClient: DependencyKey {
    static var liveValue: Self {
        return Self(
            open: { await WebSocketActor.shared.open(id: $0, url: $1, protocols: $2) },
            receive: { try await WebSocketActor.shared.receive(id: $0) },
            send: { try await WebSocketActor.shared.send(id: $0, message: $1) },
            sendPing: { try await WebSocketActor.shared.sendPing(id: $0) }
        )
    }
    
    static let testValue = Self()
}

// MARK: - WebSocket Actor

actor WebSocketActor: GlobalActor {
    static let shared = WebSocketActor()
    
    final class Delegate: NSObject, URLSessionWebSocketDelegate {
        var continuation: AsyncStream<WebSocketClient.Action>.Continuation?
        
        func urlSession(
            _: URLSession,
            webSocketTask _: URLSessionWebSocketTask,
            didOpenWithProtocol protocol: String?
        ) {
            self.continuation?.yield(.didOpen(protocol: `protocol`))
        }
        
        func urlSession(
            _: URLSession,
            webSocketTask _: URLSessionWebSocketTask,
            didCloseWith closeCode: URLSessionWebSocketTask.CloseCode,
            reason: Data?
        ) {
            self.continuation?.yield(.didClose(code: closeCode, reason: reason))
            self.continuation?.finish()
        }
    }
    
    typealias Dependencies = (socket: URLSessionWebSocketTask, delegate: Delegate)
    
    var dependencies: [WebSocketClient.ID: Dependencies] = [:]
    
    func open(id: WebSocketClient.ID, url: URL, protocols: [String]) -> AsyncStream<WebSocketClient.Action> {
        let delegate = Delegate()
        let session = URLSession(configuration: .default, delegate: delegate, delegateQueue: nil)
        let socket = session.webSocketTask(with: url, protocols: protocols)
        defer { socket.resume() }
        var continuation: AsyncStream<WebSocketClient.Action>.Continuation!
        let stream = AsyncStream<WebSocketClient.Action> {
            $0.onTermination = { _ in
                socket.cancel()
                Task { await self.removeDependencies(id: id) }
            }
            continuation = $0
        }
        delegate.continuation = continuation
        self.dependencies[id] = (socket, delegate)
        return stream
    }
    
    func close(
        id: WebSocketClient.ID, with closeCode: URLSessionWebSocketTask.CloseCode, reason: Data?
    ) async throws {
        defer { self.dependencies[id] = nil }
        try self.socket(id: id).cancel(with: closeCode, reason: reason)
    }
    
    func receive(id: WebSocketClient.ID) throws -> AsyncStream<Result<WebSocketClient.Message, Error>> {
        let socket = try self.socket(id: id)
        return AsyncStream { continuation in
            let task = Task {
                while !Task.isCancelled {
                    continuation.yield(await Result { try await WebSocketClient.Message(socket.receive()) })
                }
                continuation.finish()
            }
            continuation.onTermination = { _ in task.cancel() }
        }
    }
    
    func send(id: WebSocketClient.ID, message: URLSessionWebSocketTask.Message) async throws {
        try await self.socket(id: id).send(message)
    }
    
    func sendPing(id: WebSocketClient.ID) async throws {
        let socket = try self.socket(id: id)
        return try await withCheckedThrowingContinuation { continuation in
            socket.sendPing { error in
                if let error {
                    continuation.resume(throwing: error)
                } else {
                    continuation.resume()
                }
            }
        }
    }
    
    private func socket(id: WebSocketClient.ID) throws -> URLSessionWebSocketTask {
        guard let dependencies = self.dependencies[id]?.socket else {
            struct Closed: Error {}
            throw Closed()
        }
        return dependencies
    }
    
    private func removeDependencies(id: WebSocketClient.ID) {
        self.dependencies[id] = nil
    }
}

extension DependencyValues {
    var webSocket: WebSocketClient {
        get { self[WebSocketClient.self] }
        set { self[WebSocketClient.self] = newValue }
    }
}

Step 1: Define the Feature Domain

First, we’ll define our feature’s domain, which includes the state and actions. This forms the foundation of our TCA implementation.

@Reducer
struct BitcoinTracker {
    @ObservableState
    struct State: Equatable {
        var connectivityState = ConnectivityState.disconnected
        var currentPrice: Double = 0.0
        
        enum ConnectivityState: String {
            case connected
            case connecting
            case disconnected
        }
    }
    
    enum Action {
        case connectButtonTapped
        case receivedSocketMessage(Result)
        case webSocket(WebSocketClient.Action)
    }
    
    @Dependency(\.continuousClock) var clock
    @Dependency(\.webSocket) var webSocket
    
    var body: some Reducer {
        Reduce { state, action in
            // Reducer implementation (we'll cover this next)
        }
    }
}

Here, we define our State struct, which includes the connectivity state and current Bitcoin price. The Action enum defines all possible actions in our feature, including user interactions and WebSocket events.

Step 2: Implement the Reducer’s Body

Next, we’ll implement the Reducer’s body, which is responsible for handling state changes. This is where we define how our app responds to different actions.

var body: some Reducer {
    Reduce { state, action in
        switch action {
        case .connectButtonTapped:
            switch state.connectivityState {
            case .connected, .connecting:
                state.connectivityState = .disconnected
                return .cancel(id: WebSocketClient.ID())
                
            case .disconnected:
                state.connectivityState = .connecting
                return .run { send in
                    let actions = await self.webSocket.open(
                        id: WebSocketClient.ID(),
                        url: URL(string: "wss://ws.coincap.io/prices?assets=bitcoin")!,
                        protocols: []
                    )
                    await withThrowingTaskGroup(of: Void.self) { group in
                        for await action in actions {
                            group.addTask { await send(.webSocket(action)) }
                            if case .didOpen = action {
                                group.addTask {
                                    for await result in try await self.webSocket.receive(id: WebSocketClient.ID()) {
                                        await send(.receivedSocketMessage(result))
                                    }
                                }
                            }
                        }
                    }
                }
                .cancellable(id: WebSocketClient.ID())
            }
            
        case let .receivedSocketMessage(.success(message)):
            if case let .string(priceString) = message,
               let priceData = priceString.data(using: .utf8),
               let json = try? JSONSerialization.jsonObject(with: priceData) as? [String: String],
               let bitcoinPrice = json["bitcoin"],
               let price = Double(bitcoinPrice) {
                state.currentPrice = price
            }
            return .none
            
        case .receivedSocketMessage(.failure):
            return .none
            
        case .webSocket(.didClose):
            state.connectivityState = .disconnected
            return .cancel(id: WebSocketClient.ID())
            
        case .webSocket(.didOpen):
            state.connectivityState = .connected
            return .none
        }
    }
}

In this reducer, we handle various actions such as connecting/disconnecting the WebSocket, receiving messages, and updating the Bitcoin price.

Note how each action can result in state changes and/or effects.

Step 3: Create the View

Finally, we’ll create our SwiftUI view that uses the TCA store. This view will display the Bitcoin price and allow users to interact with our app.

struct BitcoinTrackerView: View {
    @Bindable var store: StoreOf<BitcoinTracker>
    
    var body: some View {
        VStack {
            Spacer()
            Image(systemName: "bitcoinsign")
                .font(.title3)
                .glow()
                .padding(2)
            Text("$\(store.currentPrice, specifier: "%.2f")")
                .font(.largeTitle)
                .transaction { transaction in
                    transaction.animation = .default
                }
                .contentTransition(.numericText(value: store.currentPrice))
                .glow()
            
            Spacer()
            Text("Status: \(store.connectivityState.rawValue)")
                .foregroundStyle(.secondary)
            Button(action: { store.send(.connectButtonTapped) }) {
                Text(buttonTitle)
                    .frame(maxWidth: .infinity)
            }
            .buttonStyle(.bordered)
            .buttonBorderShape(.capsule)
            .tint(buttonColor)
            
            
        }
        .monospaced()
        .padding()
    }
    
    var buttonTitle: String {
        switch store.connectivityState {
            case .connected: return "Disconnect"
            case .connecting: return "Connecting..."
            case .disconnected: return "Connect"
        }
    }
    
    var buttonColor: Color {
        switch store.connectivityState {
            case .connected: return .red
            case .connecting: return .orange
            case .disconnected: return .green
        }
    }
}

extension View {
    func glow() -> some View {
        self
            .foregroundColor(.white)
            .background {
                self
                    .shadow(color: .yellow, radius: 4)
            }
            .background {
                self
                    .shadow(color: .orange, radius: 2)
            }
            .background {
                self
                    .shadow(color: .red, radius: 12)
            }
    }
}

This view uses the @Bindable property wrapper to bind to our TCA store, allowing it to react to state changes. When the button is tapped, it sends the connectButtonTapped action to the store, which then runs it through the reducer.

Using the Store

To use our Bitcoin tracker in an app, we would create a Store and pass it to our view from the app entry point:

@main
struct MyApp: App {
    var body: some Scene {
        WindowGroup {
                BitcoinTrackerView(
                    store: Store(initialState: BitcoinTracker.State()) {
                        BitcoinTracker()
                    }
                )
        }
    }
}

This creates a new Store with the initial state of our Bitcoin tracker and the BitcoinTracker reducer. The Store will manage the state and handle actions for our feature.

Conclusion

By refactoring our Bitcoin price tracker to use The Composable Architecture, we’ve gained a deeper understanding of TCA’s core concepts:

  1. Reducer: Encapsulates our feature’s logic, defining how the state changes in response to actions.
  2. Store: Manages the runtime of our feature, holding the current state and processing actions through the reducer.
  3. State: Represents the entire state of our feature at any given time.
  4. Action: Defines all possible events that can occur in our feature.

This structure provides several benefits:

  • Clear separation of concerns between state management, business logic, and UI.
  • Improved testability of our core logic.
  • A more predictable and maintainable codebase.
  • Better handling of side effects like WebSocket connections.

While there’s a learning curve to TCA, understanding these core concepts provides a solid foundation for building complex, maintainable applications.

Further Learning and Deeper Dive

To dive deeper into TCA, Brandon Williams and Stephen Celis did an incredible job in documenting and recording sessions they explain and deep dive into the library, check out the links below to learn more:

  1. The Composable Architecture Documentation
  2. Point-Free Video Series on TCA
  3. TCA GitHub Repository

Full Code

you can copy and paste the code in Xcode and explore!

note: make sure you installed the library using SPM

Reducer and View

import ComposableArchitecture
import SwiftUI

// MARK: - Feature Domain

@Reducer
struct BitcoinTracker {
    @ObservableState
    struct State: Equatable {
        var connectivityState = ConnectivityState.disconnected
        var currentPrice: Double = 0.0
        
        enum ConnectivityState: String {
            case connected
            case connecting
            case disconnected
        }
    }
    
    enum Action {
        case connectButtonTapped
        case receivedSocketMessage(Result<WebSocketClient.Message, Error>)
        case webSocket(WebSocketClient.Action)
    }
    
    @Dependency(\.continuousClock) var clock
    @Dependency(\.webSocket) var webSocket
    
    var body: some Reducer<State, Action> {
        Reduce { state, action in
            switch action {
                case .connectButtonTapped:
                    switch state.connectivityState {
                        case .connected, .connecting:
                            state.connectivityState = .disconnected
                            return .cancel(id: WebSocketClient.ID())
                            
                        case .disconnected:
                            state.connectivityState = .connecting
                            return .run { send in
                                let actions = await self.webSocket.open(
                                    id: WebSocketClient.ID(),
                                    url: URL(string: "wss://ws.coincap.io/prices?assets=bitcoin")!,
                                    protocols: []
                                )
                                await withThrowingTaskGroup(of: Void.self) { group in
                                    for await action in actions {
                                        group.addTask { await send(.webSocket(action)) }
                                        if case .didOpen = action {
                                            group.addTask {
                                                for await result in try await self.webSocket.receive(id: WebSocketClient.ID()) {
                                                    await send(.receivedSocketMessage(result))
                                                }
                                            }
                                        }
                                    }
                                }
                            }
                            .cancellable(id: WebSocketClient.ID())
                    }
                    
                case let .receivedSocketMessage(.success(message)):
                    if case let .string(priceString) = message,
                       let priceData = priceString.data(using: .utf8),
                       let json = try? JSONSerialization.jsonObject(with: priceData) as? [String: String],
                       let bitcoinPrice = json["bitcoin"],
                       let price = Double(bitcoinPrice) {
                        state.currentPrice = price
                    }
                    return .none
                    
                case .receivedSocketMessage(.failure):
                    return .none
                    
                case .webSocket(.didClose):
                    state.connectivityState = .disconnected
                    return .cancel(id: WebSocketClient.ID())
                    
                case .webSocket(.didOpen):
                    state.connectivityState = .connected
                    return .none
            }
        }
    }
}

// MARK: - View

struct BitcoinTrackerView: View {
    @Bindable var store: StoreOf<BitcoinTracker>
    
    var body: some View {
        VStack {
            Spacer()
            Image(systemName: "bitcoinsign")
                .font(.title3)
                .glow()
                .padding(2)
            Text("$\(store.currentPrice, specifier: "%.2f")")
                .font(.largeTitle)
                .transaction { transaction in
                    transaction.animation = .default
                }
                .contentTransition(.numericText(value: store.currentPrice))
                .glow()
            
            Spacer()
            Text("Status: \(store.connectivityState.rawValue)")
                .foregroundStyle(.secondary)
            Button(action: { store.send(.connectButtonTapped) }) {
                Text(buttonTitle)
                    .frame(maxWidth: .infinity)
            }
            .buttonStyle(.bordered)
            .buttonBorderShape(.capsule)
            .tint(buttonColor)
            
            
        }
        .monospaced()
        .padding()
    }
    
    var buttonTitle: String {
        switch store.connectivityState {
            case .connected: return "Disconnect"
            case .connecting: return "Connecting..."
            case .disconnected: return "Connect"
        }
    }
    
    var buttonColor: Color {
        switch store.connectivityState {
            case .connected: return .red
            case .connecting: return .orange
            case .disconnected: return .green
        }
    }
}

extension View {
    func glow() -> some View {
        self
            .foregroundColor(.white)
            .background {
                self
                    .shadow(color: .yellow, radius: 4)
            }
            .background {
                self
                    .shadow(color: .orange, radius: 2)
            }
            .background {
                self
                    .shadow(color: .red, radius: 12)
            }
    }
}

// MARK: - WebSocket Client

@DependencyClient
struct WebSocketClient {
    struct ID: Hashable, @unchecked Sendable {
        let rawValue: AnyHashable
        
        init<RawValue: Hashable & Sendable>(_ rawValue: RawValue) {
            self.rawValue = rawValue
        }
        
        init() {
            struct RawValue: Hashable, Sendable {}
            self.rawValue = RawValue()
        }
    }
    
    enum Action {
        case didOpen(protocol: String?)
        case didClose(code: URLSessionWebSocketTask.CloseCode, reason: Data?)
    }
    
    enum Message: Equatable {
        struct Unknown: Error {}
        
        case data(Data)
        case string(String)
        
        init(_ message: URLSessionWebSocketTask.Message) throws {
            switch message {
                case let .data(data): self = .data(data)
                case let .string(string): self = .string(string)
                @unknown default: throw Unknown()
            }
        }
    }
    
    var open: @Sendable (_ id: ID, _ url: URL, _ protocols: [String]) async -> AsyncStream<Action> = {
        _, _, _ in .finished
    }
    var receive: @Sendable (_ id: ID) async throws -> AsyncStream<Result<Message, Error>>
    var send: @Sendable (_ id: ID, _ message: URLSessionWebSocketTask.Message) async throws -> Void
    var sendPing: @Sendable (_ id: ID) async throws -> Void
}

extension WebSocketClient: DependencyKey {
    static var liveValue: Self {
        return Self(
            open: { await WebSocketActor.shared.open(id: $0, url: $1, protocols: $2) },
            receive: { try await WebSocketActor.shared.receive(id: $0) },
            send: { try await WebSocketActor.shared.send(id: $0, message: $1) },
            sendPing: { try await WebSocketActor.shared.sendPing(id: $0) }
        )
    }
    
    static let testValue = Self()
}

// MARK: - WebSocket Actor

actor WebSocketActor: GlobalActor {
    static let shared = WebSocketActor()
    
    final class Delegate: NSObject, URLSessionWebSocketDelegate {
        var continuation: AsyncStream<WebSocketClient.Action>.Continuation?
        
        func urlSession(
            _: URLSession,
            webSocketTask _: URLSessionWebSocketTask,
            didOpenWithProtocol protocol: String?
        ) {
            self.continuation?.yield(.didOpen(protocol: `protocol`))
        }
        
        func urlSession(
            _: URLSession,
            webSocketTask _: URLSessionWebSocketTask,
            didCloseWith closeCode: URLSessionWebSocketTask.CloseCode,
            reason: Data?
        ) {
            self.continuation?.yield(.didClose(code: closeCode, reason: reason))
            self.continuation?.finish()
        }
    }
    
    typealias Dependencies = (socket: URLSessionWebSocketTask, delegate: Delegate)
    
    var dependencies: [WebSocketClient.ID: Dependencies] = [:]
    
    func open(id: WebSocketClient.ID, url: URL, protocols: [String]) -> AsyncStream<WebSocketClient.Action> {
        let delegate = Delegate()
        let session = URLSession(configuration: .default, delegate: delegate, delegateQueue: nil)
        let socket = session.webSocketTask(with: url, protocols: protocols)
        defer { socket.resume() }
        var continuation: AsyncStream<WebSocketClient.Action>.Continuation!
        let stream = AsyncStream<WebSocketClient.Action> {
            $0.onTermination = { _ in
                socket.cancel()
                Task { await self.removeDependencies(id: id) }
            }
            continuation = $0
        }
        delegate.continuation = continuation
        self.dependencies[id] = (socket, delegate)
        return stream
    }
    
    func close(
        id: WebSocketClient.ID, with closeCode: URLSessionWebSocketTask.CloseCode, reason: Data?
    ) async throws {
        defer { self.dependencies[id] = nil }
        try self.socket(id: id).cancel(with: closeCode, reason: reason)
    }
    
    func receive(id: WebSocketClient.ID) throws -> AsyncStream<Result<WebSocketClient.Message, Error>> {
        let socket = try self.socket(id: id)
        return AsyncStream { continuation in
            let task = Task {
                while !Task.isCancelled {
                    continuation.yield(await Result { try await WebSocketClient.Message(socket.receive()) })
                }
                continuation.finish()
            }
            continuation.onTermination = { _ in task.cancel() }
        }
    }
    
    func send(id: WebSocketClient.ID, message: URLSessionWebSocketTask.Message) async throws {
        try await self.socket(id: id).send(message)
    }
    
    func sendPing(id: WebSocketClient.ID) async throws {
        let socket = try self.socket(id: id)
        return try await withCheckedThrowingContinuation { continuation in
            socket.sendPing { error in
                if let error {
                    continuation.resume(throwing: error)
                } else {
                    continuation.resume()
                }
            }
        }
    }
    
    private func socket(id: WebSocketClient.ID) throws -> URLSessionWebSocketTask {
        guard let dependencies = self.dependencies[id]?.socket else {
            struct Closed: Error {}
            throw Closed()
        }
        return dependencies
    }
    
    private func removeDependencies(id: WebSocketClient.ID) {
        self.dependencies[id] = nil
    }
}

extension DependencyValues {
    var webSocket: WebSocketClient {
        get { self[WebSocketClient.self] }
        set { self[WebSocketClient.self] = newValue }
    }
}

// MARK: - Preview

#Preview {
    BitcoinTrackerView(
        store: Store(initialState: BitcoinTracker.State()) {
            BitcoinTracker()
        }
    )
    .preferredColorScheme(.dark)
}

App Entry Point

import SwiftUI
import ComposableArchitecture

@main
struct MyApp: App {
    var body: some Scene {
        WindowGroup {
                BitcoinTrackerView(
                    store: Store(initialState: BitcoinTracker.State()) {
                        BitcoinTracker()
                    }
                )
        }
    }
}
All notes and essays
© 2026 MangoByte. Built simple, on purpose.