Essay · Sep 2025 · 6 min read

Two SwiftUI patterns that survive a year in production.

After shipping two iOS apps in SwiftUI and maintaining them for over a year, two patterns kept earning their keep. Everything else I tried, I eventually deleted.

SwiftUI tutorials show you eight ways to do every common task. They don’t tell you which of the eight survive contact with a real codebase that gets edited every week for a year. After shipping CarCampro (15 months in production) and ProEstimate AI (since February), here’s what I keep reaching for, and what I quietly deleted.

1. The Coordinator–View split

Every screen in my apps is two files: FooView.swift and FooCoordinator.swift.

The view is pure SwiftUI: no Task, no network calls, no navigation logic, no business decisions. It takes a coordinator, reads from it via @ObservedObject, and calls methods on it for every user action. The view’s job is to render and forward.

The coordinator is an @MainActor class conforming to ObservableObject. It owns the screen’s state, performs all async work, decides what to navigate to next, and handles errors. It depends on services injected through its initializer — never a singleton, never an environment lookup.

@MainActor
final class TonightModeCoordinator: ObservableObject {
  @Published var sites: [Site] = []
  @Published var loadingState: LoadingState = .idle

  private let siteService: SiteServicing
  private let location: LocationProviding

  init(siteService: SiteServicing, location: LocationProviding) {
    self.siteService = siteService
    self.location = location
  }

  func onAppear() async { ... }
  func didTapSite(_ site: Site) { ... }
}

Why this survives: it lets me write the entire screen’s logic in a class I can unit-test without ever launching SwiftUI. SwiftUI is famously hard to test. Coordinators are not. The day I had a coordinator with full unit-test coverage was the day I stopped fearing SwiftUI refactors.

2. The Reducer–Action store (lite)

For screens with non-trivial state — a multi-step form, a paginated feed, a checkout flow — coordinators alone weren’t enough. Mutations spread out and tangled. The reducer pattern fixed it without the full ceremony of a Redux-style framework.

The shape:

enum CheckoutAction {
  case userTappedPay
  case paymentSucceeded(receipt: Receipt)
  case paymentFailed(error: PaymentError)
  case retry
}

struct CheckoutState {
  var step: Step = .summary
  var paymentInProgress = false
  var error: PaymentError? = nil
}

@MainActor
final class CheckoutStore: ObservableObject {
  @Published private(set) var state = CheckoutState()

  func send(_ action: CheckoutAction) { ... }
}

Every state change goes through send. The state is read-only from outside the store. The reducer body in send is a single function with no surprises — you can read it top to bottom and know every transition the screen can make.

Why this survives: it makes the screen’s state machine visible. When I come back to a complex screen six months later, the reducer tells me everything that can happen. Compare that to a coordinator with twenty methods that each mutate two properties — that’s a state machine too, but a hidden one.

What I deleted

The Environment-everywhere pattern

For about three months in 2024 I was injecting services through .environment(\.serviceContainer, ...). It felt clean. Then I had to refactor the service container, and every screen in the app that read it broke at runtime instead of compile-time. The compiler is the most loyal collaborator on a solo project. I let it back in. Services now go through coordinator initializers, period.

Lots of @StateObject at the leaf

I had been creating coordinators inside leaf views with @StateObject. This made them un-testable from the parent and impossible to mock. Now coordinators are created at the screen-root level and passed in. The leaf view doesn’t own anything that has external dependencies.

Combine for everything

Combine is fine for AsyncSequence-shaped concerns: a stream of CLLocation updates, a websocket. For everything else, async/await + AsyncStream is clearer, easier to debug, and doesn’t leak. I removed Combine from CarCampro’s app layer entirely about a year ago. Net positive.

The thread that runs through both

Both surviving patterns share a property: they push business logic out of the view into something testable. SwiftUI views are great at rendering. They are not great at being reasoned about, refactored, or tested. The patterns that earned their keep over twelve months are the ones that minimize how much logic ends up trapped in the view layer.

If you find yourself writing complicated onChange closures or branching NavigationStack destinations from inside a view body, that’s the smell. Pull it into a coordinator or a store. Future you will thank present you.

← All writing