3.7 KiB
Common code smells and remediation patterns
Intent
Use this reference during code-first review to map visible SwiftUI patterns to likely runtime costs and safer remediation guidance.
High-priority smells
Expensive formatters in body
var body: some View {
let number = NumberFormatter()
let measure = MeasurementFormatter()
Text(measure.string(from: .init(value: meters, unit: .meters)))
}
Prefer cached formatters in a model or dedicated helper:
final class DistanceFormatter {
static let shared = DistanceFormatter()
let number = NumberFormatter()
let measure = MeasurementFormatter()
}
Heavy computed properties
var filtered: [Item] {
items.filter { $0.isEnabled }
}
Prefer deriving this once per meaningful input change in a model/helper, or store derived view-owned state only when the view truly owns the transformation lifecycle.
Sorting or filtering inside body
List {
ForEach(items.sorted(by: sortRule)) { item in
Row(item)
}
}
Prefer sorting before render work begins:
let sortedItems = items.sorted(by: sortRule)
Inline filtering inside ForEach
ForEach(items.filter { $0.isEnabled }) { item in
Row(item)
}
Prefer a prefiltered collection with stable identity.
Unstable identity
ForEach(items, id: \.self) { item in
Row(item)
}
Avoid id: \.self for non-stable values or collections that reorder. Use a stable domain identifier.
Top-level conditional view swapping
var content: some View {
if isEditing {
editingView
} else {
readOnlyView
}
}
Prefer one stable base view and localize conditions to sections or modifiers. This reduces root identity churn and makes diffing cheaper.
Image decoding on the main thread
Image(uiImage: UIImage(data: data)!)
Prefer decode and downsample work off the main thread, then store the processed image.
Observation fan-out
Broad @Observable reads on iOS 17+
@Observable final class Model {
var items: [Item] = []
}
var body: some View {
Row(isFavorite: model.items.contains(item))
}
If many views read the same broad collection or root model, small changes can fan out into wide invalidation. Prefer narrower derived inputs, smaller observable surfaces, or per-item state closer to the leaf views.
Broad ObservableObject reads on iOS 16 and earlier
final class Model: ObservableObject {
@Published var items: [Item] = []
}
The same warning applies to legacy observation. Avoid having many descendants observe a large shared object when they only need one derived field.
Remediation notes
@State is not a generic cache
Use @State for view-owned state and derived values that intentionally belong to the view lifecycle. Do not move arbitrary expensive computation into @State unless you also define when and why it updates.
Better alternatives:
- precompute in the model or store
- update derived state in response to a specific input change
- memoize in a dedicated helper
- preprocess on a background task before rendering
equatable() is conditional guidance
Use equatable() only when:
- equality is cheaper than recomputing the subtree, and
- the view inputs are value-semantic and stable enough for meaningful equality checks
Do not apply equatable() as a blanket fix for all redraws.
Triage order
When multiple smells appear together, prioritize in this order:
- Broad invalidation and observation fan-out
- Unstable identity and list churn
- Main-thread work during render
- Image decode or resize cost
- Layout and animation complexity