Disclosure: This article was created or edited with AI assistance. Revised on 13 October 2026 to remove promotional links and unsupported outcome claims. It is educational guidance, not a verified personal case study or a measured result. Examples are illustrative; use only your own documented experience and measurements.
The sections below present options and illustrative examples. Adapt them to the requirements of your project or role; they do not establish a universal rule or promise a result.
1. Use @Observable Instead of ObservableObject
Observation tracks property access, which can narrow updates for a particular model. Measure the effect in your app:
// Old way - triggers unnecessary updates
class ViewModel: ObservableObject {
@Published var name = ""
@Published var age = 0
}
// New way - only updates when accessed properties change
@Observable
class ViewModel {
var name = ""
var age = 0
}
The new macro tracks only the properties your view actually reads.
2. Avoid Expensive Computations in body
Never do heavy work inside your view's body:
// BAD - computed every render
var body: some View {
let filtered = items.filter { $0.isActive }.sorted()
List(filtered) { item in
Text(item.name)
}
}
// GOOD - cached in ViewModel
var body: some View {
List(viewModel.filteredItems) { item in
Text(item.name)
}
}
3. Use LazyVStack and LazyHStack
Lazy stacks create content as needed and can prefetch near the visible area. Compare with a regular stack using profiling:
// BAD - loads 10,000 items at once
ScrollView {
VStack {
ForEach(items) { item in
ExpensiveRow(item: item)
}
}
}
// GOOD - loads only visible items
ScrollView {
LazyVStack {
ForEach(items) { item in
ExpensiveRow(item: item)
}
}
}
4. Add Stable IDs to ForEach
SwiftUI uses IDs to track changes. Unstable IDs cause unnecessary redraws:
// BAD - index changes when items are reordered
ForEach(items.indices, id: \.self) { index in
ItemRow(item: items[index])
}
// GOOD - stable ID from data model
ForEach(items) { item in
ItemRow(item: item)
}
5. Use EquatableView for Complex Views
Equatable can allow SwiftUI to skip some updates when the compared value is unchanged. Equality must include everything relevant to rendering:
struct ProfileCard: View, Equatable {
let user: User
static func == (lhs: ProfileCard, rhs: ProfileCard) -> Bool {
lhs.user.id == rhs.user.id &&
lhs.user.name == rhs.user.name
}
var body: some View {
// Complex view here
}
}
// Usage
EquatableView(content: ProfileCard(user: user))
6. Cache Images Properly
Don't reload images on every render:
// Basic loading example; inspect actual requests and caching behavior
AsyncImage(url: imageURL) { image in
image.resizable()
} placeholder: {
ProgressView()
}
// An explicit cache is an option when measurement shows a need
// Or implement your own cache
struct CachedAsyncImage: View {
let url: URL
@State private var image: UIImage?
var body: some View {
Group {
if let image {
Image(uiImage: image)
.resizable()
} else {
ProgressView()
}
}
.task(id: url) {
image = nil
let loaded = await ImageCache.shared.load(url)
guard !Task.isCancelled else { return }
image = loaded
}
}
}
7. Use drawingGroup() for Complex Graphics
For a measured rendering bottleneck, compare drawingGroup with the original implementation:
// Renders as a single flattened image
ZStack {
ForEach(0..<100) { i in
Circle()
.fill(Color.blue.opacity(0.1))
.frame(width: CGFloat(i * 3))
}
}
.drawingGroup() // Measure rendering time and memory before adopting this
8. Debounce Search Input
Don't trigger API calls on every keystroke:
@MainActor
@Observable
class SearchViewModel {
var searchText = ""
var results: [Item] = []
private var searchTask: Task<Void, Never>?
func search() {
searchTask?.cancel()
searchTask = Task {
try? await Task.sleep(for: .milliseconds(300))
guard !Task.isCancelled else { return }
do {
let found = try await api.search(searchText)
guard !Task.isCancelled else { return }
results = found
} catch is CancellationError {
return
} catch {
// Expose an error state appropriate to your UI.
}
}
}
}
9. Use task(id:) Correctly
Cancel and restart tasks when data changes:
struct UserDetailView: View {
let userID: Int
@State private var user: User?
var body: some View {
VStack {
if let user {
Text(user.name)
}
}
.task(id: userID) { // Restarts when userID changes
user = try? await api.fetchUser(userID)
}
}
}
10. Profile with Instruments
Use Xcode's Instruments to find real bottlenecks:
- Product → Profile (Cmd+I)
- Choose SwiftUI template
- Look for:
- High "body invocations"
- Long "View body" times
- Frequent "State changes"
Quick Reference
| Symptom | Investigation |
|---|---|
| Slow scrolling | Profile construction and layout; compare container types |
| Stuttering animations | Identify layout/rendering work; compare drawingGroup only if relevant |
| Frequent updates | Inspect observed properties and identity; validate any equality check |
| Memory spikes | Measure allocations, decoded-image size, cache limits and task lifetime |
| Laggy search | Debounce input |
Re-measure after each change and keep a reproducible comparison.
Scope of the code examples
These are separate teaching snippets, not a complete tested application or a production-ready package. Match availability to your deployment target, provide omitted model/service definitions, and check errors, cancellation, accessibility and persistence before adapting them.
Measure before choosing a lazy container or a rendering optimization. A change such as drawingGroup can add costs; it is not a general fix for choppy animation. See Apple's container guidance and performance guide.
Top comments (0)