Skip to content

Recommended Reading

Learn Swift Composable Architecture

24 Minutes

Learn Swift Composable Architecture

Fix Bugs Faster! Log Collection Made Easy

Get started

Introduction

Swift Composable Architecture (TCA) is one of the cleanest and most scalable ways to build iOS and macOS apps today.

Created by Point-Free, it pulls together state management, side effects, dependency injection, and modular design into one consistent and predictable system. Whether we’re crafting a tiny feature or designing a full-scale app, TCA helps us write Swift code that’s easier to test, easier to work out, and a lot less painful to maintain over time.

In this article we’re going to show you how to maximize the benefits of TCA, by building a counter feature for our app (in other words, a feature that can count items or events). Along the way, we’ll dig into all aspects of composable architecture, so by the end, you should be able to approach the subject with confidence.

What is Swift Composable Architecture (TCA)?

TCA is a Swift-first library for building apps with predictable, testable architecture. Inspired by Elm and Redux, it brings those functional programming concepts into a Swift-friendly design that prioritizes modularity and ergonomics.

At the heart of TCA are three core pieces:

  • State: A single, centralized snapshot of our app’s data.
  • Reducer: A pure function that transforms state in response to actions.
  • Store: The engine that wires state and reducer together, allowing views to observe changes and dispatch actions.

Why TCA over MVVM or MVC?

MVC and MVVM might work fine at the start, but once our app starts growing, they tend to buckle under the weight of the functionality. This leads to bloated view controllers, tangled logic, and a data flow that’s hard to follow.

Composable architecture flips the script. Here’s why more developers are switching over:

  • Clear data flow: With composable architecture, data moves in one direction only. No guessing where or how something changed.
  • Composable Architecture: TCA breaks features into clean, testable chunks. Need to plug one module into another? It’s easy.
  • Test-Friendly by design: Reducers are pure functions: easy to isolate, quick to test, and free from UI noise.
  • Side effects, tamed: We can smoothly handle async work like API calls or timers without wrecking our logic. Combine does the heavy lifting, and TCA keeps it tidy.
  • No more mystery dependencies: When we use composable architecture, everything’s passed in explicitly. No globals sneaking around. Which means it’s easier to test, and easier to reason about.

Core concepts of composable architecture

Unidirectional data flow (UDF)

This is the engine that drives TCA. Think of it as a one-way street, which ensures that data flows in one direction and everything that happens in our app follows the same clear path.

Here’s the loop:

  1. View fires an Action such as a button tap, a scroll, or a lifecycle trigger.
  2. Store catches the Action, and hands it off to the Reducer.
  3. Reducer updates the State and optionally kicks off an Effect for example, a network call.
  4. Effects run async. When done, they can send more Actions back into the system.
  5. State changes trigger UI updates, which means the view is re-rendered with the new state.

This loop gives us one state, one place to handle logic, and zero guesswork. No rogue state changes. No side effects popping up out of nowhere. It’s clean, traceable, and rock-solid under pressure.

Now let’s understand state, action, environment, and reducer

These four ingredients are the backbone of TCA and composable architecture in general. They shape how our logic lives, how data flows, and how side-effects are managed. Once we get comfortable with this pattern, composing new features will feel less like a puzzle and more like muscle memory.

State

Our state struct holds all the data needed to render the UI. It’s a snapshot of our screen’s entire world: no global shortcuts, no mystery properties from five files away.

struct CounterState: Equatable {
    var count = 0
}

Action

Actions represent the things that can happen. The user taps, timers fire, API returns anything that should cause a change in state or trigger an effect.

enum CounterAction: Equatable {
    case increment
    case decrement
}

Environment

This is our boundary to the outside world. The Environment carries our dependencies, like schedulers, API clients and UUID generators, so our logic stays pure and testable.

struct CounterEnvironment {
    var mainQueue: AnySchedulerOf<DispatchQueue>
}

Reducer

The reducer is the engine of composable architecture. It listens for actions, mutates the state accordingly, and kicks off any side effects via Effect. No hidden state, no implicit logic, just one function that describes how our app evolves over time.

let counterReducer = Reducer<CounterState, CounterAction, CounterEnvironment> { 
state, action, env in
switch action {
case .increment:
state.count += 1
return .none

case .decrement:
state.count -= 1
return .none
}
}let counterReducer = Reducer<CounterState, CounterAction, CounterEnvironment> { state, action, env in
    switch action {
    case .increment:
        state.count += 1
        return .none
    case .decrement:
        state.count -= 1
        return .none
    }
}

Effects and side effects

Reducers are great for straightforward state changes. But what about the messy, real-world stuff like network calls, timers or file reads? This is where Effects come in. In TCA, effects handle any asynchronous issues or side-effects, all modeled neatly with Combine publishers wrapped in Effect<Action>.

This clear separation between state mutations and side effects lets us:

  • Pinpoint exactly where and why side effects happen.
  • Write unit tests that cover every branch of your logic without mocking half our app.
  • Keep our reducers pure and our async logic declarative and reusable.
case .loginButtonTapped:
    return environment.authClient
        .login(username: state.username, password: state.password)
        .receive(on: environment.mainQueue)
        .catchToEffect()
        .map(LoginAction.loginResponse)

Ok. Now let’s set up our TCA project!

First, install TCA with Swift package manager

TCA is available to install as a Swift Package, which makes adding it super easy. Here’s a quick and painless way to bring this into our project:

  1. Fire up Xcode and go to File → Add Packages…
  2. In the search bar, paste the TCA GitHub URL. <https://github.com/pointfreeco/swift-composable-architecture>
  3. Choose our version rule (latest major is usually a safe bet).
  4. Add the package to our app target.

After that, we’re ready to import TCA into our Swift files:

import ComposableArchitecture

Structuring our TCA feature

Each TCA feature lives and breathes through its State, Action, Environment, and Reducer. We could dump everything in one file, but our future self will thank us for keeping things modular!

If we’re building a counter app, then a recommended directory layout for a feature like a counter might look like this:

/Features
  /Counter
    CounterState.swift
    CounterAction.swift
    CounterEnvironment.swift
    CounterReducer.swift
    CounterView.swift

Or, if we like fewer files and don’t mind longer files:

CounterFeature.swift // All your state, action, reducer, environment in one spot
CounterView.swift    // The SwiftUI view

Building the barebones feature

Now let’s create a simple counter feature to see how everything connects.

State & Action

struct CounterState: Equatable {
    var count = 0
}

enum CounterAction: Equatable {
    case increment
    case decrement
    case reset

}

Environment & reducer

struct CounterEnvironment {}

let counterReducer = Reducer<CounterState, CounterAction, CounterEnvironment> { state, action, _ in
    switch action {
    case .increment:
        state.count += 1
        return .none
    case .decrement:
        state.count -= 1
        return .none
    case .reset:
        state.count = 0
        return .none
    }
}

Connecting to SwiftUI

Great, now we come to an important step, where all the key parts of component architecture come together. It’s time to bring everything to life in our SwiftUI view:

struct CounterView: View {
    let store: Store<CounterState, CounterAction>

    var body: some View {
        WithViewStore(self.store) { viewStore in
            VStack(spacing: 16) {
                Text("Count: \(viewStore.count)")
                    .font(.largeTitle)

                HStack(spacing: 20) {
                    Button("−") { viewStore.send(.decrement) }
                    Button("+") { viewStore.send(.increment) }
                    Button("Reset") { viewStore.send(.reset) }
                }
            }
            .padding()
        }
    }
}

Bootstrapping the app

Finally, we can wire everything up in our App entry point:

@main
struct MyApp: App {
    var body: some Scene {
        WindowGroup {
            CounterView(
                store: Store(
                    initialState: CounterState(),
                    reducer: counterReducer,
                    environment: CounterEnvironment()
                )
            )
        }
    }
}

Adding side effects (timers, async events)

Now let’s level up our counter app by adding a timer that automatically increases the count every second. This will show us how composable architecture makes handling asynchronous side effects clear, testable, and predictable.

1. Extend our actions

We can add new cases to start the timer and handle each tick, like so:

enum CounterAction: Equatable {
    case increment
    case decrement
    case reset
    case startTimer
    case timerTick
}

2. Update the environment

Our feature also needs to define a timer effect and a scheduler so we can control when ticks happen. Here’s the updated CounterEnvironment:

struct CounterEnvironment {
    var mainQueue: AnySchedulerOf<DispatchQueue>
    var timer: () -> Effect<CounterAction, Never>
}

We can provide a live implementation that kicks off a timer emitting. .timerTick will go off every second.

extension CounterEnvironment {
    static let live = CounterEnvironment(
        mainQueue: DispatchQueue.main.eraseToAnyScheduler(),
        timer: {
            Timer.publish(every: 1.0, on: .main, in: .common)
                .autoconnect()
                .map { _ in CounterAction.timerTick }
                .eraseToEffect()
        }
    )
}

3. Update the Reducer

So, we’re ready to handle the new startTimer and timerTick actions in our reducer. When .startTimer is sent, the timer effect starts emitting .timerTick actions are as follows:

let counterReducer = Reducer<CounterState, CounterAction, CounterEnvironment> { state, action, env in
    switch action {
    case .increment:
        state.count += 1
        return .none
    case .decrement:
        state.count -= 1
        return .none
    case .reset:
        state.count = 0
        return .none
    case .startTimer:
        return env.timer()
            .receive(on: env.mainQueue)
            .eraseToEffect()
    case .timerTick:
        state.count += 1
        return .none
    }
}

4. Add a start timer button

Finally, we will add a button to the view so users can kick off the timer:

Button("Start Timer") {
    viewStore.send(.startTimer)
}

Debugging in Composable Architecture with Swift

Composable architecture gives us powerful tools to peek under the hood of our app’s state and action flow, and this is particularly true of TCA. No extra libraries, no fancy setup. These built-in debugging helpers can save us hours when tracking down why our state isn’t doing what we expect.

1. Using .debug() on the Store

When we create our Store, it’s easy to tack on .debug("YourLabel"). This will log every action sent and every state change that follows. It’s ridiculously simple and incredibly effective:

Store(
    initialState: CounterState(),
    reducer: counterReducer,
    environment: .live
)
.debug("Counter")

This will print something like this right in our console every time we tap a button or trigger an action:

Counter: received action:
  CounterAction.increment
Counter: state changed:
  CounterState(count: 1)

2. Debugging specific reducers

If we want to get laser-focused and watch a single feature instead of our entire app, we can apply .debug() directly to an individual reducer:

let debuggedReducer = counterReducer.debug("CounterReducer")

This is perfect because, as our project grows, we can focus purely on what’s happening inside one slice of our app’s state without getting overwhelmed by unrelated logs.

3. Debug Swift Composable Architecture apps at scale with Bugfender

When our Swift Composable Architecture app grows into multiple features or teams, local tools like .debug() and Reducer.debug() aren’t enough.

By injecting Bugfender as a logging dependency, we can capture detailed, production-safe logs from every reducer and effect, without changing our clean architecture. This gives all teams consistent visibility into state changes, user actions, and errors across the entire app.

Bugfender’s remote logging makes it easy to filter by feature, user, or session, helping us quickly pinpoint issues that only appear in real-world environments. It’s a scalable way to keep debugging efficient, even in the most complex composable architecture projects.

Navigating and composing features

As our SwiftUI app grows beyond a handful of screens, features need to play nicely together—or our codebase will turn into a spaghetti mess overnight! The beauty of TCA is that it gives us the tools to build features that work independently, yet snap together seamlessly.

Why use scope?

Scope is a very popular TCA reducer. It allows us to convert parent domains into child domains and thus break down larger features into small, easily manageable ones. Specifically, scope isolates feature logic and keeps our parent views clean.

To understand the benefits of scope, let’s imagine we have an app-wide state (AppState) that contains a profile feature’s state (ProfileState).

struct AppState: Equatable {
    var profile = ProfileState()
}

enum AppAction: Equatable {
    case profile(ProfileAction)
}

When we build our AppView, we don’t want to dump the entire AppState into our profile UI. Instead, we scope it down:

ProfileView(
    store: store.scope(
        state: \.profile,
        action: AppAction.profile
    )
)

Now our ProfileView only sees the bits of state and actions it actually needs—no unnecessary baggage!

Parent-Child state and actions

One of the superpowers of composable architecture is the way it lets us build hierarchies: parent states embed child states, and parent actions wrap child actions. This structure lets us break a massive app into tidy little trees of features.

State composition

struct ProfileState: Equatable {
    var username: String = ""
    var email: String = ""
}

struct AppState: Equatable {
    var profile = ProfileState()
    var isLoggedIn = false
}

Action composition

enum ProfileAction: Equatable {
    case updateUsername(String)
    case updateEmail(String)
}

enum AppAction: Equatable {
    case profile(ProfileAction)
    case logout
}

This nesting means our parent can delegate behavior to the child reducer without entangling everything into one giant reducer from hell.

Pullback: Sharing reducers between parent and child

Once we have nested state and actions, we need a way for our child reducers to plug into the parent. This is where pullback comes in: it adapts a child reducer to work with a parent’s state, actions, and environment.

let profileReducer = Reducer<ProfileState, ProfileAction, ProfileEnvironment> { state, action, env in
    // logic here
}

let appReducer = Reducer<AppState, AppAction, AppEnvironment>.combine(
    profileReducer.pullback(
        state: \.profile,
        action: /AppAction.profile,
        environment: { $0.profile }
    ),
    Reducer { state, action, _ in
        // app-specific logic
        return .none
    }
)

This pattern shines in big apps, because each reducer focuses only on its own business, but still contributes to a cohesive whole.

CombineReducers: Stitching multiple reducers together

Rather than stuffing every piece of logic into a single reducer, TCA encourages us to keep things lean by combining multiple reducers. This results in a modular, maintainable architecture that scales gracefully – one of the core goals and principles of composable architecture (if you haven’t noticed by now!)

let appReducer = Reducer.combine(
    authReducer,
    settingsReducer,
    dashboardReducer
)

Each reducer handles its own feature’s state and actions, but they all come together in our root reducer, keeping our app easy to extend without making it a tangled nightmare.

Advanced patterns in TCA

Once our app starts to outgrow the basics, we’ll bump into scenarios where state and action trees get deep, screens appear or disappear dynamically, and we’re juggling collections of state. This is where TCA’s advanced patterns kick in, letting us keep everything predictable and testable, even when our app is a behemoth.

Let’s break down how to wrangle three other cornerstones of composable architecture: complex hierarchies, optional state, and dynamic lists.

Managing complex state and action hierarchies

As our app becomes more feature-rich, we’ll probably start seeing our state tree looking like a Russian nesting doll: dashboards with embedded modules, multi-step flows, or multi-pane UIs. That’s perfectly fine, as long as we break things down properly.

Strategies for managing complexity:

  • Split state into smaller structs so each feature has its own well-defined data.
  • Nest state and actions so parent features can scope or delegate behavior.
  • Keep reducers focused, avoiding giant monoliths.
struct AppState {
    var settings: SettingsState
    var profile: ProfileState
    var analytics: AnalyticsState
}

enum AppAction {
    case settings(SettingsAction)
    case profile(ProfileAction)
    case analytics(AnalyticsAction)
}

With this setup, each reducer can operate on its own island, and we get a clean, composable architecture even in apps with dozens of features.

Handling navigation with optional state

In real-world apps, we have certain screens that don’t always exist, like detail views that only show when the user taps on something. Modeling these flows means representing optional state.

Why optional state?

Here’s some code to show you:

struct AppState {
    var detail: DetailState?  // Might be `nil` when screen is not presented
}

Actions for navigation

enum AppAction {
    case detail(DetailAction)
    case showDetail(Bool)
}

Optional reducer composition

This gives us a fully scoped detail feature that’s only alive when we need it—and easy to test independently.

let appReducer = Reducer<AppState, AppAction, AppEnvironment>.combine(
    detailReducer.optional().pullback(
        state: \.detail,
        action: /AppAction.detail,
        environment: { $0.detail }
    ),
    Reducer { state, action, environment in
        switch action {
        case .showDetail(let isVisible):
            state.detail = isVisible ? DetailState() : nil
            return .none
        case .detail:
            return .none
        }
    }
)

Working with dynamic lists using IdentifiedArrayOf

Ok, now here’s a conundrum: What if we need a list of items where each one has its own state and logic, like a to-do list, chat threads, or shopping cart? That’s where TCA’s IdentifiedArrayOf shines, letting us manage an array of states in a way that’s both ergonomic and testable.

State for a dynamic list

struct AppState {
    var todos: IdentifiedArrayOf<TodoState> = []
}

Actions to drive list behavior

enum AppAction {
    case todos(IdentifiedActionOf<TodoState, TodoAction>)
    case addTodo
}

Reducer for lists

let appReducer = Reducer<AppState, AppAction, AppEnvironment>.combine(
    todoReducer.forEach(
        state: \.todos,
        action: /AppAction.todos,
        environment: { $0.todo }
    ),
    Reducer { state, action, environment in
        switch action {
        case .addTodo:
            state.todos.append(TodoState(id: UUID(), title: "New Task"))
            return .none
        case .todos:
            return .none
        }
    }
)

Rendering in SwiftUI

ForEachStore(self.store.scope(
    state: \.todos,
    action: AppAction.todos
)) { todoStore in
    TodoRow(store: todoStore)
}

Testing with TCA

Composable architecture doesn’t just allow us to test our code. It practically begs us to do so!

By making state, actions, and effects explicit and deterministic, TCA gives us everything we need to write clear, reliable, and fast tests — whether we’re checking business logic or complex async flows.

Unit testing reducers

Reducers in TCA are pure functions — and that’s a huge win for testing. Since they don’t depend on external state or side effects, we can drop them into a test, feed in an initial state and an action, and verify the resulting state directly.

func testIncrementAction() {
    var state = CounterState(count: 0)
    let environment = CounterEnvironment()
    _ = counterReducer.run(&state, .increment, environment)

    XCTAssertEqual(state.count, 1)
}

Testing effects with TestStore

For testing more than just state mutations, like how our app handles side effects, async calls, or multiple actions firing in sequence, TCA’s TestStore is our go-to. With this handy tool, we can send actions, step through received responses, and assert changes at each point.

func testFetchTodos() {
    let testEnvironment = AppEnvironment(apiClient: .mockWithTodos, mainQueue: .immediate)
    let store = TestStore(
        initialState: AppState(),
        reducer: appReducer,
        environment: testEnvironment
    )

    store.send(.fetchTodos) {
        $0.isLoading = true
    }
    store.receive(.todosResponse(.success([Todo(id: 1, title: "Test")]))) {
        $0.isLoading = false
        $0.todos = [Todo(id: 1, title: "Test")]
    }
}

Real-world project structure

Composable architecture might look like overkill, if we focus on just a few isolated examples. However, the real magic of TCA shows up when our app starts to scale.

Large, feature-rich apps live or die on maintainability, and TCA gives us the structure to keep things clean, testable, and future-proof. Now let’s break down how to organize our project so we’re ready for the challenges of a production codebase.

1) Feature modules and domain separation

For truly large apps, we should consider organizing our features into standalone Swift Packages. This enforces clear boundaries, reduces coupling, and lets multiple teams work independently without stepping on one another’s toes.

Why modularize?

  • It creates clear domain boundaries (like Authentication, Chat, and Shopping).
  • It lets us write independent tests for each module.
  • It supports reuse across multiple apps or extensions.
  • It improves build times with incremental compilation and more granular CI jobs.

2) Using TCA with clean architecture principles

This is where the magic starts to happen!

Pairing TCA with clean architecture unlocks serious long-term maintainability. By structuring our code into distinct layers, we avoid tangled dependencies and keep logic testable in isolation.

Different layers of a clean TCA app

  • Domain Layer: Core models, value objects, and pure logic, no external dependencies here.
  • Feature Layer: TCA modules with state, actions, reducers, and environment for each feature.
  • Data Layer: Services for API calls, database interactions, and other I/O.
  • App/UI Layer: SwiftUI views and navigation wired up to TCA stores.

Tips & best practices

Ok, we’re nearing the finish line now! But it’s important to bear some key points in mind, to ensure we get the absolute most out of TCA and composable architecture in general:

  • Don’t bring in TCA if you’ve got a tiny screen with just a handful of state variables. You should reach for TCA when you’ve got state and behaviors that span multiple screens, or when you know you’ll need serious testing and scalability.
  • Skip the urge to split your state and actions into a bunch of pointless layers. Keep it lean when it makes sense.
  • For small, self-contained screens, it’s usually better to stick with ObservableObject, plain State, or Binding.
  • Make sure each reducer sticks to one clear responsibility. Don’t let it become a catch-all mess.
  • When your logic starts growing, break it out into smaller feature reducers so everything stays easy to manage.
  • Use Scope or CombineReducers to piece reducers together cleanly.
  • Always handle side effects in your Environment and write your tests using TestStore so you’re not flying blind.
  • Sprinkle in .debug() or .debugReducer() calls to see exactly what’s happening with your state and actions as they flow through your app.
  • Take advantage of Time Travel Debugging tools (like Point-Frees) when you’re wading through tricky, multi-step interactions.
  • Keep your directory structure tidy and stick to consistent naming for state and actions.

To sum up

Swift Composable Architecture (TCA) gives us a rock-solid, test-friendly way to build modern iOS apps. By embracing TCA’s core components (State, Action, Reducer, Environment, and Store) and wiring them up with SwiftUI, we set ourselves up with a foundation that’s way more maintainable than old-school patterns like MVC or even MVVM. And the secret sauce? Unidirectional data flow, which makes our app’s state and actions explicit, predictable, and easy to reason about.

As our app grows, composable architecture scales right along with us. Scoped reducers, parent–child state hierarchies, and helpers like IdentifiedArrayOf make it a easy to keep our code modular and our features independent. And when we start juggling asynchronous side effects, TCA really shines, giving us clear, testable ways to handle things like API calls, timers, or complex workflows with TestStore.

In this article we have discussed many of the core concepts of composable architecture (State, Action, Reducer and Environment) by understanding the fundamentals and real life examples of how we can define the code in an easy, modular structure. We have also learned some of the best practices around TCA.

In a real-world, production-scale app, pairing TCA with Clean Architecture principles gives us a blueprint for long-term success. We get a system that’s modular, testable, and ready for a team to work on without stepping on each other’s toes.

Ok, that’s enough from us. Happy Coding!

Expect The Unexpected!

Debug Faster With Bugfender

Start for Free
blog author

Sachin Siwal

Sachin is an accomplished iOS developer and seasoned technical manager, combining 12 years of mobile app expertise with a knack for leading and delivering innovative solutions. You can contact him on Linkedin

Join thousands of developers
and start fixing bugs faster than ever.