How to Use SwiftUI Effectively in Production Apps

SwiftUI has grown up enough to be my default for shipping apps, but making it work well isn’t just a matter of swapping UIKit for a declarative syntax. Over the past two years, I’ve built several client-facing apps entirely in SwiftUI, and I’ve found that real-world success comes from architectural discipline, knowing where performance breaks, and a practical eye for when to still reach for UIKit. Here are the concrete techniques I lean on to keep SwiftUI predictable and maintainable as a codebase grows.

Open laptop with code on screen and coffee cup on wooden desk

Architectural Patterns That Scale

SwiftUI re-evaluates its view body a lot. The single biggest architecture mistake I keep seeing is business logic stuffed directly into views. So I pull everything apart with a unidirectional data flow. I run a lightweight MVVM variant where each screen gets a dedicated ObservableObject view model. User actions update the view model, the view model mutates its @Published properties, and SwiftUI redraws only the pieces that actually changed.

For navigation, NavigationStack with value-based paths—thank you iOS 16—swept away a pile of coordinator hacks. I define a navigation enum per feature module and push values onto the path. Deep linking becomes a simple matter of setting the path, and I don’t miss the tangled delegate patterns that made UIKit navigation feel like a puzzle.

State Management: Which Property Wrapper to Use When

Picking the right property wrapper stops over-rendering and stale data before they start. My rule of thumb looks like this:

  • @State – local, transient UI state owned by a single view. Toggle states, text field input, and modal presentation flags live here.
  • @Binding – when a child view needs read-write access to a parent’s @State. I limit bindings to one level of nesting; anything deeper creates tight coupling that comes back to bite you during refactors.
  • @StateObject – for view models the view itself creates and owns. This is the lifecycle entry point for a screen.
  • @ObservedObject – when the view model is injected from a parent. The parent holds ownership; the child just watches for changes.
  • @EnvironmentObject – reserved for truly global dependencies, like authentication state or theme settings. I don’t use it as a shortcut to pass data that should flow through explicit initializers.

One production trap that shows up again and again is using @ObservedObject where @StateObject belongs. The view model gets recreated on every body evaluation, state resets, and you’re left scratching your head. Xcode 15’s runtime warnings catch this now, but I still squint at every @ObservedObject during code review and ask, “Does the parent really own this?”

Developer pointing at code on a monitor during a team review

Performance: Making SwiftUI Fast by Default

SwiftUI’s diffing algorithm handles a lot, but big view bodies with heavy conditional branching can still drop frames. I profile every new feature with Instruments’ SwiftUI template, hunting for View body evaluation spikes. When I spot needless re-evaluations, I reach for three focused fixes.

First, I pull static subviews into their own structs with Equatable conformance. SwiftUI compares old and new view instances; if they’re equal, it skips the body call entirely. A profile header that shows a static name and avatar, for instance, shouldn’t redraw just because a sibling’s like count changed. Making it Equatable stops that.

Second, I’m careful with @ViewBuilder conditionals. Swapping if condition { ViewA() } else { ViewB() } for opacity or offset modifiers sometimes preserves view identity and avoids tearing down and recreating the whole sub-tree. Inside List and LazyVStack, where cell recycling magnifies costs, this matters a lot.

Third, heavy work moves off the main thread. Task blocks in .task modifiers are cooperative, but I also wrap image processing and data transformation in dedicated actors. Swift 5.9’s custom actor executors let me pin database work to a serial background queue, so the UI thread stays free for what it does best—rendering.

List and ScrollView Performance

List is the backbone of most iOS apps, and how it performs directly shapes how users feel about the app. I always feed explicit id parameters to ForEach so SwiftUI can track identity across updates. For datasets that push past 500 items, I switch to LazyVStack inside a ScrollView with manual prefetching. Under the hood, List can still load cells eagerly in certain cases, and that creates memory pressure you don’t need.

I also keep row hierarchies flat. A row built from five nested stacks and a handful of image downloads will stutter on fast scrolls. I stick to direct HStack and VStack layouts, and on rows with lots of shapes or shadows, I apply .drawingGroup(). That flattens the row into a single bitmap before rendering and cuts compositing time noticeably.

Interoperability: Mixing UIKit and SwiftUI

No production app I’ve shipped is pure SwiftUI. Map views, camera interfaces, rich text editors—they still need UIKit. The trick is making the boundary clean so neither side leaks assumptions into the other.

When I wrap a UIKit view, I create a struct conforming to UIViewRepresentable and treat it like a leaf node. All configuration goes through a dedicated configuration struct, not a scatter of @Binding properties. That makes the representable testable by itself. Inside updateUIView, I only apply changes when the new configuration actually differs from the current one—a quick equality check saves unnecessary UIKit layout passes.

Going the other direction, embedding SwiftUI in UIKit with UIHostingController works, but intrinsic content size can act up. I override viewDidLayoutSubviews in the parent UIKit controller, call sizeToFit on the hosting controller’s view, then update constraints. Without that, SwiftUI content sometimes collapses to zero height inside stack views.

iPhone displaying app interface next to a notepad with handwritten notes

Testing Strategy for SwiftUI Code

SwiftUI views are structs; they don’t carry an intrinsic lifecycle, so unit testing them directly is a dead end. I focus tests on view models and leave UI behavior to snapshot and integration tests.

For view models, I lean on XCTest with dependency injection. Every view model receives its dependencies—network services, database managers, analytics trackers—as protocols in its initializer. Tests supply mock implementations that return canned data or record calls. I can verify that a button tap triggers the right service method and updates the published state, no guessing needed.

Snapshot testing with swift-snapshot-testing catches visual regressions. I set up a test host app that renders critical screens in fixed conditions—specific locale, dark and light mode, a range of dynamic type sizes—and compares them against reference images. These run on CI and have flagged layout breaks before users ever saw them.

Preview-Driven Development

Xcode Previews aren’t just a design tool for me; they’re a fast feedback loop for logic changes. I create previews for every state a screen can land in: loading, loaded with data, empty, error. Each preview seeds the view model with fixed data. The canvas becomes a visual checklist—I flip through states and confirm the UI handles each one correctly long before I fire up the simulator.

For previews that need network data, I define a PreviewContent enum with static methods that return mock responses. That keeps previews snappy and repeatable, no local server required.

Common Pitfalls and How to Avoid Them

These are the issues I keep bumping into in production SwiftUI code, and the fixes I apply:

  • Modifier order matters. .frame(width: 100).padding() produces a different result than .padding().frame(width: 100). I document chains with comments that show the intended layout math so the next person doesn’t have to guess.
  • Animation glitches. Implicit animations with .animation(.default, value: myValue) bleed into all child views. I stick to explicit withAnimation blocks for discrete state changes and keep implicit animations only on leaf views.
  • Memory leaks from closures. Capturing self strongly inside a Task block that outlives the view keeps the view model alive. I use [weak self] or structured concurrency with task cancellation tied to the view’s .task modifier.
  • Overusing AnyView. Type-erasing views kills SwiftUI’s ability to diff efficiently. I only use AnyView when it’s truly unavoidable for type homogeneity, like storing heterogeneous views in an array.

FAQ

When should I choose SwiftUI over UIKit for a new production app?

Choose SwiftUI if your minimum deployment target is iOS 16 or later, and you don’t need deep camera, map, or rich text features that still lean on UIKit. SwiftUI’s navigation and data flow are solid from iOS 16 onward. If you need to support iOS 15, SwiftUI is still possible, but you’ll find yourself writing UIKit wrappers for navigation and some advanced controls.

How do I handle complex navigation flows in SwiftUI?

I use NavigationStack with a navigation path array. Define an enum that covers every possible destination in a feature module. Push values onto the path for standard forward navigation, and use navigationDestination(for:) to map each enum case to a view. For modal flows, pair .sheet and .fullScreenCover with the same enum approach. Deep linking stays straightforward because you can set the whole path programmatically from a URL or push notification.

What’s the best way to manage dependencies in SwiftUI apps?

Inject dependencies through the view hierarchy. Use SwiftUI’s .environment() for truly global objects like a theme manager, and pass explicit dependencies—services, repositories—through view model initializers. I avoid the service locator pattern where views grab dependencies from a global container; it hides the real dependency graph and makes testing a chore.

Can I use SwiftUI for an app that requires custom drawing or animations?

Yes, SwiftUI’s Canvas and TimelineView handle custom drawing well, and matchedGeometryEffect paired with phaseAnimator (iOS 17) enables complex, choreographed animations. For drawing that demands high frame rates—think a waveform visualizer—wrap a UIKit CADisplayLink in a representable and pipe data to a SwiftUI overlay. That gives you pixel-level control while keeping the rest of the UI declarative.

Using SwiftUI in production isn’t about chasing every new feature. It’s about knowing which parts are stable, where the performance edges are, and how to drop back to UIKit when it makes sense. With the patterns I’ve laid out here, I’ve shipped apps that stay fast, testable, and ready for whatever the next iOS release throws at us.