Recommended Reading

24 Minutes
Learn Swift Composable Architecture
Fix Bugs Faster! Log Collection Made Easy
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.
Table of Contents
- Introduction
- What is Swift Composable Architecture (TCA)?
- Core concepts of composable architecture
- Ok. Now let’s set up our TCA project!
- First, install TCA with Swift package manager
- Structuring our TCA feature
- Building the barebones feature
- Connecting to SwiftUI
- Bootstrapping the app
- Adding side effects (timers, async events)
- 1. Extend our actions
- 2. Update the environment
- 3. Update the Reducer
- 4. Add a start timer button
- Debugging in Composable Architecture with Swift
- 1. Using .debug() on the Store
- 2. Debugging specific reducers
- 3. Debug Swift Composable Architecture apps at scale with Bugfender
- Navigating and composing features
- Advanced patterns in TCA
- Testing with TCA
- Real-world project structure
- Tips & best practices
- To sum up
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:
- View fires an Action such as a button tap, a scroll, or a lifecycle trigger.
- Store catches the Action, and hands it off to the Reducer.
- Reducer updates the State and optionally kicks off an
Effect
for example, a network call. - Effects run async. When done, they can send more Actions back into the system.
- 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:
- Fire up Xcode and go to
File → Add Packages…
- In the search bar, paste the TCA GitHub URL.
<https://github.com/pointfreeco/swift-composable-architecture>
- Choose our version rule (
latest major
is usually a safe bet). - 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
}
}
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.
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.
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
, plainState
, orBinding
. - 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
orCombineReducers
to piece reducers together cleanly. - Always handle side effects in your
Environment
and write your tests usingTestStore
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