Introduction
After 78 days of building with Swift and SwiftUI, you've likely shipped features that work. But working isn't the same as performing well, and performant isn't the same as maintainable. We've seen production apps grind to a halt and seen teams spend weeks debugging issues that could have been caught early. This article covers common SwiftUI mistakes: real-world lessons and examples drawn from e-commerce platforms, CRM dashboards, and ride-hailing apps where performance and reliability matter.
Heavy Work Inside Body
The body property in SwiftUI isn't a place to do serious work. It's called frequently—often multiple times per frame during animations or state updates—and any expensive operation there compounds into sluggish UIs.
We worked on a travel app that fetched geocoding data, parsed JSON, and performed string transformations directly in the body of a row view. On a list of 100 destinations, scrolling was visibly janky. Here's what that looked like:
struct DestinationRow: View {
let data: String
var body: some View {
VStack {
// BAD: Heavy work in body
let coordinates = parseCoordinates(data)
let formatted = formatAddressString(coordinates)
let imageURL = fetchImageURL(formatted)
Text(formatted)
AsyncImage(url: imageURL)
}
}
}
Every time this view recomputes, all three functions run again. If the parent updates for any reason, the child recalculates needlessly. The fix is to move work upstream into a view model or to use computed properties and lazy evaluation:
struct DestinationRow: View {
let data: String
@State private var viewModel: RowViewModel?
var body: some View {
VStack {
if let vm = viewModel {
Text(vm.formatted)
AsyncImage(url: vm.imageURL)
}
}
.onAppear {
Task {
viewModel = await RowViewModel(from: data)
}
}
}
}
@MainActor
class RowViewModel: ObservableObject {
let formatted: String
let imageURL: URL?
init(from data: String) async {
let coordinates = parseCoordinates(data)
self.formatted = formatAddressString(coordinates)
self.imageURL = try? await fetchImageURL(self.formatted)
}
}
The second approach defers work to initialization or to the view model's lifecycle, keeping the body lean and fast. For truly immutable data, consider pre-computing in your data layer entirely.
Unstable IDs in ForEach
ForEach requires stable identifiers. If your id changes between renders or isn't unique, SwiftUI can't track which view corresponds to which data item. This causes animation glitches, incorrect state binding, and mysteriously duplicated or missing rows.
A CRM team ran into this with a contact list that sorted by last-interacted date. They used index as the identifier without thinking:
struct ContactList: View {
@State var contacts: [Contact]
var body: some View {
List {
// BAD: Index is not stable when list reorders
ForEach(contacts.indices, id: \.self) { index in
ContactRow(contact: contacts[index])
}
}
}
}
When the list reordered, the index for each contact changed, and SwiftUI lost track of which row belonged to whom. Expand/collapse state got attached to the wrong contact. The fix is to use the contact's unique identifier:
struct ContactList: View {
@State var contacts: [Contact]
var body: some View {
List {
// GOOD: Use stable, unique id
ForEach(contacts, id: \.id) { contact in
ContactRow(contact: contact)
}
}
}
}
If your model doesn't have an id field, add one. A UUID or server-assigned identifier is always better than positional data. Never use derived or computed values as ids unless they're guaranteed immutable for the lifetime of that view hierarchy.
Creating Observable Objects Inside Body
Instantiating an ObservableObject or StateObject inside body is a common trap. The object gets recreated on every render, losing state and wasting memory. We saw this in a trading app's real-time price feed:
struct PriceWidget: View {
let symbol: String
var body: some View {
// BAD: New observer created on every body render
let priceObserver = PriceObserver(symbol: symbol)
VStack {
Text("Price: \(priceObserver.price)")
}
.onAppear {
Task {
await priceObserver.startFetching()
}
}
}
}
Each render call creates a new observer, the old one gets deallocated, the task cancels, and no data flows. The price never updates. Use @StateObject to own the lifecycle of an observable object:
struct PriceWidget: View {
let symbol: String
@StateObject private var priceObserver: PriceObserver
init(symbol: String) {
self.symbol = symbol
_priceObserver = StateObject(wrappedValue: PriceObserver(symbol: symbol))
}
var body: some View {
VStack {
Text("Price: \(priceObserver.price)")
}
.onAppear {
Task {
await priceObserver.startFetching()
}
}
}
}
SwiftUI retains the StateObject across renders, so the observer lives as long as the view does. Tasks complete, data flows, and prices update. If you're receiving an observable object from a parent, use @ObservedObject. If the object is injected via the environment, use @EnvironmentObject. Each has its place.
Overusing @EnvironmentObject
@EnvironmentObject is convenient for global app state. Too convenient. We've seen apps where half the views were reading from a massive EnvironmentObject, making dependencies invisible and testing nearly impossible.
An e-commerce app had a monolithic AppState environment object passed down from the root:
@MainActor
class AppState: ObservableObject {
@Published var user: User?
@Published var cart: [CartItem] = []
@Published var recommendations: [Product] = []
@Published var notifications: [Notification] = []
@Published var darkMode: Bool = false
// ... 20 more properties
}
struct ProductDetail: View {
@EnvironmentObject var appState: AppState
var body: some View {
// Which properties does this view actually need?
// Hard to know. Hard to test.
}
}
Every view that needed even one piece of app state reached into the environment. Refactoring AppState broke unknown dependents. Testing required setting up the entire object. The healthier pattern is to extract smaller, focused objects and pass them explicitly:
@MainActor
class CartManager: ObservableObject {
@Published var items: [CartItem] = []
}
@MainActor
class UserManager: ObservableObject {
@Published var user: User?
}
struct ProductDetail: View {
@StateObject private var cartManager: CartManager
@ObservedObject var userManager: UserManager
init(cartManager: CartManager, userManager: UserManager) {
self._cartManager = StateObject(wrappedValue: cartManager)
self.userManager = userManager
}
var body: some View {
// Clear dependencies. Easy to test.
}
}
Reserve EnvironmentObject for truly global, rarely-changing settings like theme or localization. For feature-specific state, pass objects explicitly. Your code becomes more readable, more testable, and easier to refactor.
Common Mistakes at a Glance
| Mistake | Impact | Fix |
|---|---|---|
| Heavy computation in body | Frame drops, janky scrolling | Move to view model, compute on init or in tasks |
| Index-based ForEach ids | Broken animations, mismatched state | Use stable, unique model identifiers |
| ObservableObject created in body | State loss, tasks cancelled, no updates | Use @StateObject, initialize in init() |
| EnvironmentObject everywhere | Hidden dependencies, hard to test | Pass objects explicitly, limit EnvironmentObject to global config |
Key Takeaways
- Keep the
bodyproperty thin and fast. Offload heavy work to view models, initializers, or tasks that run once per lifecycle event. - Always use stable, unique identifiers in
ForEach. Never rely on index position when data can reorder. - Use
@StateObjectto own observable objects within a view. Don't recreate them on every render. - Prefer explicit parameter passing over
@EnvironmentObjectfor feature-specific state. Keep dependencies visible and testable. - Profile early and often. Use Instruments to spot frame drops and excessive view recomputation before they ship.






Comments
Loading comments…