Blog post
Charlie Oxendine
March 15, 2026

Implementing The Composable Architecture in LifeApp: Starting with the Notes Feature

Implementing The Composable Architecture in LifeApp: Starting with the Notes Feature

If you've spent any time in the iOS development community, you've probably heard of The Composable Architecture (TCA) by Point-Free. It promises predictable state management, testability, and, as the name suggests, composability. After watching my SwiftUI codebase grow increasingly tangled, I decided to try TCA inside LifeApp, my all-in-one productivity app that combines tasks, habits, calendar, fitness tracking, and notes into a single experience.

Rather than rewrite the whole app, I picked a single feature to experiment with. Here's how it went, including a maddening bug that cost me way too much time.

Why TCA? Why Now?

LifeApp started as a relatively simple task and calendar app. Over time, it grew into something much more ambitious: habit tracking, live workout sessions with Apple Watch, strength training logs, rich text notes, Strava integration, customizable dashboards, the list kept expanding (although I plan on pumping the breaks a little bit in the near future to just polish and improve what we already have)

With each new feature, the state management story got messier. I had @State and @StateObject scattered across views, side effects firing from various places, and cross-feature communication held together with NotificationCenter posts and callback closures. It worked, but it was getting harder to reason about. When a bug appeared, I'd often have to trace through three or four different files to figure out which piece of state was stale or which effect was firing at the wrong time.

TCA offered a clear answer to these problems. Every feature gets a well-defined State struct, an Action enum, and a Reducer that contains all the business logic. Side effects are explicit and testable. It felt like exactly the kind of structure I needed.

Why Notes Was the Right Starting Point

I didn't want to migrate everything at once. LifeApp has a lot of surface area; tasks, habits, calendar integration, multiple workout types, notes, settings, an Apple Watch companion. A full rewrite would have meant weeks of nothing compiling while I tried to wire everything together. That's a nightmare for a side project you're shipping updates on regularly.

Instead, I picked Notes as a contained experiment.

Notes was a good candidate for a few reasons. It was one of the newer features in the app, so it had less legacy baggage. It had a clear boundary; creating, editing, tagging, and exporting notes, without too many tentacles reaching into other parts of the codebase. And it was complex enough to be a real test of TCA's patterns, with rich text editing, PDF export, tag management, and link saving from the share extension.

The goal was straightforward: build the Notes feature with TCA, see how the architecture felt in practice, and then decide whether it made sense to adopt more broadly across the app.

What TCA Looks Like in Practice

For anyone unfamiliar, here's the general shape of a TCA feature. A very simplified version of the Notes feature reducer looks something like this:

@Reducer

struct NotesFeature {

   @ObservableState

   struct State: Equatable {

       var notes: IdentifiedArrayOf<Note> = []

       var selectedNote: Note?

       var isEditing: Bool = false

       var filterTag: String?

   }

   enum Action {

       case onAppear

       case noteSelected(Note)

       case deleteNote(Note)

       case tagFilterChanged(String?)

       case notesLoaded([Note])

   }

   var body: some ReducerOf<Self> {

       Reduce { state, action in

           switch action {

           case .onAppear:

               return .run { send in

                   let notes = try await notesClient.fetchAll()

                   await send(.notesLoaded(notes))

               }

           case let .noteSelected(note):

               state.selectedNote = note

               state.isEditing = true

               return .none

           case let .deleteNote(note):

               state.notes.remove(id: note.id)

               return .run { _ in

                   try await notesClient.delete(note)

               }

           // ...

           }

       }

   }

}

Every mutation happens in the reducer. Every side effect is returned as an Effect. The view just reads state and sends actions. It's a clean separation that makes the data flow obvious, and more importantly, testable.

Even scoped to just one feature, the benefits were immediately noticeable. The Notes code was easier to follow than equivalent features elsewhere in the app. When something went wrong, I knew exactly where to look. The reducer is the single source of truth for what happens and when.

The Bug That Almost Kept me From Committing to TCA: Swift Actor Isolation vs. @Reducer

Here's where things got interesting. After setting up the Notes feature reducer, building the state and actions, wiring up the view, everything looked correct. The code was clean. I was feeling good. Then I was hit with this error.

this struct doesn't conform to Reducer

I stared at it for a while. The State was there. The Action enum was there. The body was there. Everything matched the TCA documentation and examples. I checked my imports, cleaned the build folder, restarted Xcode, burnt some sage and said some encantations over my mac, you know the drill. Nothing helped.

I started questioning whether I'd somehow broken the @Reducer macro conformance. Maybe I had a typo in a type name. Maybe my TCA version was wrong. I went down a rabbit hole of comparing my code character-by-character against the Point-Free tutorials. Turns out it had nothing to do with TCA at all. It was Swift's default actor isolation.

Newer Swift toolchains (starting around Xcode 26) infer main actor isolation more aggressively than before. My reducer struct was silently getting isolated to the main actor, and that isolation broke the Reducer protocol conformance. But here's the kicker: the compiler doesn't mention isolation in the error message. It just gives you the generic "doesn't conform to Reducer" error, which sends you looking in completely the wrong direction.

The fix was absurdly simple:

nonisolated struct NotesFeature {

   // ... everything else unchanged

}

One keyword. The error disappeared instantly.

This is a known pain point in the TCA community right now. It's not really a TCA bug, it's a Swift concurrency and macro interaction issue. But the misleading compiler error makes it especially frustrating. If you're adopting TCA with a recent Xcode toolchain and you see "this struct doesn't conform to Reducer" when everything looks correct, check your isolation first. This should be fixed in upcoming versions of swift...very heavy on the should.

Early Impressions

Having Notes running on TCA while the rest of the app uses vanilla SwiftUI has been an interesting contrast. A few observations so far:

The good: State management in Notes is dramatically cleaner than in other parts of the app. There's a single place where business logic lives, side effects are explicit, and the unidirectional data flow eliminates entire categories of bugs where state gets out of sync. Testing the reducer is also straightforward. You send actions and assert on the resulting state, which is exactly as simple as it sounds.

The learning curve: TCA has real overhead when you're getting started. The concepts aren't hard individually (reducers, actions, effects, stores) but wiring them together for the first time takes some adjustment, especially if you're used to reaching for @State and letting SwiftUI handle things. Point-Free's documentation and video series are excellent, but there's a lot of material to absorb.

The boilerplate: There's no getting around the fact that TCA requires more code up front than vanilla SwiftUI for the same feature. You're defining state types, action enums, reducers, and dependency clients where before you might have had a view model with a few published properties. That tradeoff pays for itself as complexity grows, but for simple screens it can feel like overkill.

What's Next

I'm not rushing to migrate the rest of LifeApp to TCA. The Notes experiment gave me a solid understanding of how the architecture works in practice, and I like what I see. But LifeApp ships updates frequently, and I need to weigh the cost of a broader migration against continuing to build new features. If I do expand TCA further, the task management system would probably be next. It has similar characteristics to Notes: clear boundaries, well-defined state, lots of user actions.

Beyond just the architecture itself, TCA is also a stepping stone toward something I've been wanting to do for a while: modularizing LifeApp. The way TCA encourages you to build self-contained features maps perfectly onto splitting each feature into its own Swift package or target. Imagine a NotesFeature module, a TasksFeature module, a FitnessFeature module, each developed and compiled independently, then imported into the main app target. That kind of structure does wonders for build times, enforces clear boundaries between features, and makes it much easier to work on one area of the app without accidentally breaking another. I'll save the details for a future post, but TCA is laying the groundwork for that.

For now, Notes is running smoothly on TCA, and I have a much better sense of what adopting this architecture really looks like for LifeApp.