Making SwiftUI Stick in Real Production Apps
SwiftUI landed in 2019 looking more like a prototyping toy than a framework you’d bet a business on. A few years later, that’s changed. It’s quietly running inside shipping apps everywhere—indie side projects and enterprise suites alike. But making it hum in production takes more than knowing your stacks from your spacers. You need opinions about architecture, a feel for performance, and a tolerance for the weird platform quirks that only surface under real user load.
I’m Yuki Tanaka. Over the last two years I’ve shipped three production apps that lean hard on SwiftUI, and I’ve collected a list of things that actually hold up—and a longer list of things that fall apart when you least expect it. What follows is the practical stuff: view structure, state handling, render tuning, and those awkward edge cases you don’t see until a few thousand people start tapping around.

Architecting Views So You Don’t Hate Yourself Later
Apps in production accumulate scars. Requirements drift, designers rework flows, and suddenly the screen that was clean three months ago is a pile of modifiers you’re afraid to touch. The first mistake I keep bumping into is the giant view body—layout, logic, and styling tangled together like last year’s Christmas lights. SwiftUI’s declarative style almost invites it; another .padding() or .background() right there in line feels harmless until you need to change something.
Pull Out Reusable Pieces Early
If a chunk of view appears twice, or even just feels like its own idea, give it a struct. This isn’t only about not repeating yourself. A smaller view body gives SwiftUI less work when it diffs and updates the hierarchy. On one project, pulling a messy list row into its own ProductRow view smoothed out scroll jank—not because we wrote anything fancy, just because we narrowed what got re-rendered.
Name things like you mean it. ProductRow beats ListCell when you’ve got five kinds of lists. Skip the generic ContentView everywhere except the root. Hand each component exactly the data it needs through its initializer. Avoid tossing things in via environment objects that nobody can trace later.
Keep Presentation and Business Logic in Separate Rooms
SwiftUI views shouldn’t know about network calls, data munging, or branching logic that belongs in a decision-maker. I push all of that out. A view’s job shrinks to one thing: describing the UI for whatever state it’s handed. The state itself lives in a view model or a service layer that the view watches.
In practice, I lean on MVVM. Screen-level view models get @StateObject. Child components that share a parent’s model use @ObservedObject. A product detail screen, for example, gets a ProductDetailViewModel that talks to a repository, formats prices, and exposes a @Published property. The view never touches the networking layer. This means I can unit test the view model without spinning up any UI, and I can swap the backend later without opening a single SwiftUI file.
State Management That Doesn’t Crumble Under Pressure
SwiftUI hands you a handful of property wrappers. Pick the wrong one and you’ll get weird bugs or views that update far more than they should. After a few misadventures, I’ve landed on a consistent set of habits.
Match the Wrapper to the Job
@State stays local—transient UI stuff a single view owns, like whether a sheet is visible or the current value of a slider. For data that originates outside the view and gets shared, @StateObject and @ObservedObject carry the load. I build view models as classes conforming to ObservableObject. The owning view initializes them with @StateObject. Child views receive the same instance as @ObservedObject.
@EnvironmentObject works for truly global dependencies—a user session or a theme manager—but it’s easy to sprinkle it everywhere. I cap environment objects at a small set of well-known types and document them. An implicit dependency that crashes at runtime because somebody forgot to inject it is a debugging headache that’s entirely avoidable.
Don’t Let Observable Objects Get Too Fat
An observable object that publishes twenty properties forces every subscribing view to re-evaluate its body on any change. I break large models into smaller, focused ones. A user profile screen might have separate observable objects for account settings, payment history, and notification preferences. Each chunk of UI watches only the slice of state it actually cares about.
In one app, splitting a monolithic DashboardViewModel into three smaller models cut the CPU time spent in view diffing by over 30% on older devices. Tab switches went from sluggish to snappy, and the difference was obvious even without Instruments.

Performance Tuning for Interactions That Feel Real
SwiftUI’s diffing engine is clever, but it’s not clairvoyant. Production apps that scroll through long lists, update frequently, or pile on views need deliberate attention to stay smooth.
Give Views a Stable Identity
SwiftUI tracks views by identity across updates. Inside ForEach, always supply a stable, unique identifier. If your data conforms to Identifiable, you get this for free. For ad-hoc data, passing \.self can backfire: if the collection order shifts, every item gets recreated. I create lightweight structs with persistent IDs, usually derived from server-side identifiers.
Identity matters outside lists too. When you conditionally show a view with if, SwiftUI treats the true and false branches as separate views with separate lifetimes. If you need state to survive the toggle, use opacity or a custom transition instead, or hoist the state to a common ancestor.
Cut Down Unnecessary Body Evaluations
A view’s body evaluates whenever any observed property changes, even if the change doesn’t touch that view. You can’t stop this wholesale, but you can shrink the blast radius. Computed subviews that take @ViewBuilder closures can hide what really triggers a re-evaluation, so I use them sparingly.
For expensive work inside a view body, I move the calculation to the view model and cache the result. Image processing, string formatting, date math—do it once in the view model, not on every redraw. The view just displays the pre-computed value.
Equatable views help in narrow cases. If you conform your view to Equatable and wrap it with EquatableView, SwiftUI skips the body evaluation when new data matches the old. This pays off most for small, data-driven components that get the same values over and over, like cells in a big list.
Lean on Lazy Stacks and Grids
LazyVStack and LazyHStack—iOS 14 and later—are non-negotiable for any list that might hold more than a screenful. They create views only as they scroll into view. I use them as the backbone for feeds, search results, and any dynamically sized content area. Pair them with List when you want native styling, swipe actions, or edit mode.
For grids, LazyVGrid and LazyHGrid bring the same lazy behavior. Define your columns with flexible or fixed sizes and SwiftUI handles the rest. On a photo gallery app I worked on, swapping a custom scroll view for a lazy grid cut memory usage by half when showing thousands of thumbnails.

Handling Platform Differences Without Losing Your Mind
SwiftUI’s cross-platform story is genuine, but it’s not automatic. Apps that target iOS and macOS—or just a spread of device sizes—have to account for different interaction patterns and layout habits.
Conditional Modifiers and Platform Checks
I use #if os(iOS) and #if os(macOS) sparingly. They fork your code at compile time and make it harder to reason about later. When the difference is purely visual—say, a different font size or padding value—I pull it into a design constants file that returns platform-specific values. For behavioral differences, like a right-click context menu on macOS versus a long-press on iOS, a protocol-based approach works nicely: define a PlatformInteraction protocol, supply iOS and macOS implementations, and inject the right one at the app level.
Adapting to Dynamic Type and Rotation
SwiftUI handles Dynamic Type reasonably well if you stick with system fonts. Custom layouts, though, can break when text sizes balloon. I test every screen at the largest accessibility text size. Often, wrapping content in a ScrollView is enough to stop clipping. For dense dashboards, I switch from a grid to a single-column stack when horizontal space gets tight, using @Environment(\.horizontalSizeClass) to decide at runtime.
Rotation support is another place where testing exposes gaps. A view that looks polished in portrait can collapse in landscape. I use ViewThatFits to offer alternative layouts when available space changes. It’s a clean, declarative way to handle size variation without manual geometry math.
Testing and Debugging When Things Get Real
Production apps need tests that run reliably and debugging tools that point at the problem quickly. SwiftUI’s opaque rendering makes this trickier than UIKit, but a few habits keep it manageable.
Unit Test View Models, Skip the View Itself
I don’t write UI tests for SwiftUI views. The layout engine is too dynamic, and snapshot tests break with every OS update. Instead, I focus on unit testing view models and integration testing the data flow. A view model’s published properties should be predictable given a set of inputs. I use dependency injection to hand it mock services and verify it emits the right states.
For critical user flows, I keep a small suite of XCUITests that confirm screens appear and basic interactions still work. These run on CI and catch regressions that pure unit tests miss—like a navigation link that goes dead after a refactor.
Debugging View Redraws
When performance dips, I reach for Self._printChanges() inside a view’s body. Available since iOS 15, it logs the names of changed properties to the console. It’s a fast way to spot unnecessary updates from an observable object that’s publishing too eagerly.
Instruments’ SwiftUI profiling template is another underused tool. It shows view body invocations, layout passes, and drawing calls on a timeline. I’ve used it to hunt down a custom GeometryReader that was causing a layout feedback loop—something invisible during code review.
Preparing for the Long Haul
Shipping with SwiftUI is one milestone. Keeping that app healthy across OS releases is another. I’ve learned to treat SwiftUI’s evolution as an advantage, not a threat. Apple fixes bugs and adds APIs every year, and your code should be ready to adopt them without a rewrite.
I follow the Swift Evolution forums and the annual WWDC sessions closely. When a new SwiftUI version offers a cleaner way to do something I’ve implemented by hand—like the .searchable modifier that replaced custom search bars—I block out time to migrate. The short-term cost pays back in less code and better accessibility, basically for free.
Finally, I keep a set of “SwiftUI survival” notes for each project. They document workarounds for known bugs, places where I’ve bridged to UIKit with UIViewRepresentable, and any assumptions about the view hierarchy that could break later. When a new team member joins, or I come back to a project after six months, those notes save hours of head-scratching.
Frequently Asked Questions
When should I avoid SwiftUI and stick with UIKit?
SwiftUI still has gaps around rich text editing, complex collection view layouts with custom interactive transitions, and deep integration with camera or ARKit pipelines. If your app centers on one of those, UIKit might be the more practical choice for that specific screen. You can always embed UIKit views where needed and use SwiftUI everywhere else.
How do I handle navigation in a production SwiftUI app?
I use NavigationStack with a typed path for iOS 16 and later. That gives you programmatic control over the navigation stack—essential for deep linking, state restoration, and testing. For earlier iOS versions, NavigationView with a binding-based approach still works, but I plan a migration path to the newer API as soon as the deployment target allows.
What’s the biggest mistake developers make with SwiftUI in production?
Treating it like UIKit with different syntax. SwiftUI’s declarative model rewards small, focused views and punishes giant view hierarchies that recompute needlessly. The biggest mistake is building a screen as one enormous view body with dozens of modifiers and conditional branches. Start small, compose relentlessly, and keep state as close to where it’s used as possible.
Can I use SwiftUI for an app that supports iOS 14 and earlier?
Yes, but you’ll miss newer APIs like .searchable, .refreshable, and the improved list and navigation views. You’ll write more custom code or conditional compilation. I recommend setting your minimum deployment target based on the features you actually need, not an arbitrary version number. Many production apps target iOS 15 as a reasonable baseline today.