From kiln
Audit SwiftUI code for @Observable macro best practices and performance
npx claudepluginhub moonlightbyte/kiln# SwiftUI @Observable Macro Audit
Perform a comprehensive audit of SwiftUI code for @Observable macro best practices, performance optimization, and iOS 17+ compliance.
## Instructions
Analyze the code for correct @Observable usage patterns, migration from ObservableObject, proper binding techniques, and thread safety. Reference Apple's official Observation framework documentation.
## Audit Checklist
### @Observable Macro Usage (Critical Priority)
{{#if (or (eq focus "macro") (eq focus "all") (not focus))}}
- [ ] **OB-001**: Using `@Observable` macro instead of `ObservableObject` confor...Perform a comprehensive audit of SwiftUI code for @Observable macro best practices, performance optimization, and iOS 17+ compliance.
Analyze the code for correct @Observable usage patterns, migration from ObservableObject, proper binding techniques, and thread safety. Reference Apple's official Observation framework documentation.
{{#if (or (eq focus "macro") (eq focus "all") (not focus))}}
@Observable macro instead of ObservableObject conformance@Published needed)@ObservationIgnored for non-tracked propertiesfinal or explicitly allowed to be subclassed@Observable with @Published (conflicting patterns)@State with Observable instances, not @StateObjectlet or @Bindable (not @State){{#if (or (eq focus "migration") (eq focus "all") (not focus))}}
: ObservableObject class conformance@Observable macro to class declaration@Published property wrappers@ObservedObject with @Bindable in child views@StateObject with @State for owned instancesEnvironmentObject with @Environment when appropriateobjectWillChange property (automatic in @Observable)@StateObject initialization with _var = StateObject(wrappedValue:)@EnvironmentObject combined with @Bindable simultaneously.onReceive(viewModel.objectWillChange) removed
{{/if}}{{#if (or (eq focus "binding") (eq focus "all") (not focus))}}
@Bindable when child view needs property bindings@Bindable applied to received Observable instance parameters$ prefix syntax works correctly for property bindings@State used for Observable properties (use @Bindable){{#if (or (eq focus "macro") (eq focus "all") (not focus))}}
@ObservationIgnored@ObservationIgnored@ObservationIgnored@ObservationIgnored if read-only@ObservationIgnored@ObservationIgnored is applied only to stored properties
{{/if}}{{#if (or (eq focus "performance") (eq focus "all") (not focus))}}
withObservationTracking if advanced{{#if (or (eq focus "macro") (eq focus "all") (not focus))}}
@MainActor for UI updatesMainActor.runUnsafely or @MainActor methods{{#if (or (eq focus "macro") (eq focus "all") (not focus))}}
@ObservedReactiveObject or Combine publishers in Observable.sink() subscriptions on Observable properties.filter() logic from property declarations
{{/if}}{{#if (or (eq focus "performance") (eq focus "all") (not focus))}}
equatable conformance needed for change detection{{#if (or (eq focus "binding") (eq focus "all") (not focus))}}
@State, not @StateObject@State initialization uses closure: @State var model = MyModel()@State Observable classes do not get recreated on view redraw@Environment with custom key@StateObject wrapper around @Observable class
{{/if}}{{#if (or (eq focus "macro") (eq focus "all") (not focus))}}
willSet/didSet on all properties{{#if (or (eq focus "testing") (eq focus "all") (not focus))}}
class EventViewModel: ObservableObject {
@Published var events: [Event] = []
@Published var isLoading = false
@Published var errorMessage: String?
private let apiClient: APIClient
init(apiClient: APIClient) {
self.apiClient = apiClient
}
func loadEvents() {
isLoading = true
Task {
do {
events = try await apiClient.fetchEvents()
isLoading = false
} catch {
errorMessage = error.localizedDescription
isLoading = false
}
}
}
}
struct EventListView: View {
@StateObject private var viewModel: EventViewModel
@ObservedObject var userSettings: UserSettings
init(apiClient: APIClient, userSettings: UserSettings) {
_viewModel = StateObject(wrappedValue: EventViewModel(apiClient: apiClient))
self.userSettings = userSettings
}
var body: some View {
List(viewModel.events) { event in
EventRow(event: event, viewModel: viewModel)
}
}
}
struct EventRow: View {
let event: Event
@ObservedObject var viewModel: EventViewModel
var body: some View {
HStack {
Text(event.name)
Spacer()
Button("Join") {
// viewModel.joinEvent(event)
}
}
}
}
@Observable
final class EventViewModel {
var events: [Event] = []
var isLoading = false
var errorMessage: String?
@ObservationIgnored
private let apiClient: APIClient
init(apiClient: APIClient) {
self.apiClient = apiClient
}
func loadEvents() {
isLoading = true
Task {
do {
events = try await apiClient.fetchEvents()
isLoading = false
} catch {
errorMessage = error.localizedDescription
isLoading = false
}
}
}
}
struct EventListView: View {
@State private var viewModel: EventViewModel
let userSettings: UserSettings
init(apiClient: APIClient, userSettings: UserSettings) {
_viewModel = State(wrappedValue: EventViewModel(apiClient: apiClient))
self.userSettings = userSettings
}
var body: some View {
List(viewModel.events) { event in
EventRow(event: event, viewModel: viewModel)
}
}
}
struct EventRow: View {
let event: Event
@Bindable var viewModel: EventViewModel
var body: some View {
HStack {
Text(event.name)
Spacer()
Button("Join") {
// viewModel.joinEvent(event)
}
}
}
}
@Observable
final class SearchViewModel {
var searchText = ""
var results: [SearchResult] = []
var isLoading = false
@ObservationIgnored
private let apiClient: APIClient
func performSearch() {
guard !searchText.isEmpty else {
results = []
return
}
isLoading = true
Task {
do {
results = try await apiClient.search(query: searchText)
} catch {
results = []
}
isLoading = false
}
}
}
struct SearchView: View {
@State private var viewModel = SearchViewModel(apiClient: .shared)
var body: some View {
VStack {
// View only accesses viewModel.searchText and viewModel.results
// Accessing these properties automatically tracks them
// viewModel.isLoading updates don't trigger view redraw
// unless isLoading is actually used in the view body
TextField("Search", text: $viewModel.searchText)
.onChange(of: viewModel.searchText) { _, _ in
viewModel.performSearch()
}
if viewModel.results.isEmpty {
Text("No results")
} else {
List(viewModel.results) { result in
SearchResultRow(result: result)
}
}
}
}
}
@Observable
final class FormViewModel {
var name: String = ""
var email: String = ""
var isSubscribed: Bool = false
var country: String = "USA"
var isValid: Bool {
!name.isEmpty && !email.isEmpty
}
}
struct FormView: View {
@State private var viewModel = FormViewModel()
var body: some View {
Form {
TextField("Name", text: $viewModel.name)
TextField("Email", text: $viewModel.email)
Toggle("Subscribe", isOn: $viewModel.isSubscribed)
Picker("Country", selection: $viewModel.country) {
Text("USA").tag("USA")
Text("Canada").tag("Canada")
}
SubmitButton(viewModel: viewModel)
}
}
}
struct SubmitButton: View {
@Bindable var viewModel: FormViewModel
var body: some View {
Button(action: {}) {
Text("Submit")
}
.disabled(!viewModel.isValid)
}
}
@Observable
@MainActor
final class NetworkViewModel {
var data: [Item] = []
var isLoading = false
var error: Error?
@ObservationIgnored
private let apiClient: APIClient
nonisolated init(apiClient: APIClient) {
self.apiClient = apiClient
}
func fetchData() {
isLoading = true
Task {
do {
let items = try await apiClient.fetchItems()
await MainActor.run {
self.data = items
self.isLoading = false
}
} catch {
await MainActor.run {
self.error = error
self.isLoading = false
}
}
}
}
}
Generate a structured audit report:
## Audit Results: [File/Component Name]
### Critical Issues (๐ด)
List issues that block functionality, cause crashes, or break thread safety.
Each with: Line number, description, severity, fix suggestion.
### Important Issues (๐ )
List issues that violate Observable best practices or hurt performance.
### Warnings (๐ก)
List suboptimal patterns that work but should be improved.
### Suggestions (๐ต)
List polish opportunities and performance optimizations.
### Summary
- Total issues: X
- Critical: X | Important: X | Warning: X | Suggestion: X
- Observable compliance: X%
- Performance impact: [Excellent | Good | Fair | Poor]
### Recommended Actions
1. [Most urgent fix - usually thread safety or macro usage]
2. [Second priority - migration or binding issue]
3. [Third priority - performance optimization]
### Performance Projection
- Estimated improvement over ObservableObject: X%
- Fine-grained reactivity: [Yes | No - opportunities exist]
## Audit Results: EventViewModel.swift
### Critical Issues (๐ด)
**Line 5**: Missing @Observable macro on Observable class
```swift
// BAD
class EventViewModel: ObservableObject {
@Published var events: [Event] = []
}
// GOOD
@Observable
final class EventViewModel {
var events: [Event] = []
}
Severity: ๐ด Critical | Confidence: 100 | Rule: OB-001
Line 45: Using @StateObject with Observable class
// BAD
@StateObject private var viewModel = EventViewModel()
// GOOD
@State private var viewModel = EventViewModel()
Severity: ๐ด Critical | Confidence: 100 | Rule: OB-008
Line 32: Missing @MainActor for UI updates
// BAD
class EventViewModel: ObservableObject {
@Published var events: [Event] = []
func loadEvents() async {
events = try await fetchRemote()
}
}
// GOOD
@Observable
@MainActor
final class EventViewModel {
var events: [Event] = []
func loadEvents() async {
events = try await fetchRemote()
}
}
Severity: ๐ด Critical | Confidence: 95 | Rule: OB-047
Line 8: Using @Published with @Observable
// BAD
@Observable
final class EventViewModel {
@Published var events: [Event] = []
}
// GOOD
@Observable
final class EventViewModel {
var events: [Event] = []
}
Severity: ๐ Important | Confidence: 100 | Rule: OB-005
Line 78: Using @ObservedObject instead of @Bindable
// BAD
struct EventRow: View {
@ObservedObject var viewModel: EventViewModel
}
// GOOD
struct EventRow: View {
@Bindable var viewModel: EventViewModel
}
Severity: ๐ Important | Confidence: 100 | Rule: OB-014
Line 12: APIClient not marked @ObservationIgnored
// BAD
@Observable
final class EventViewModel {
private let apiClient: APIClient
}
// GOOD
@Observable
final class EventViewModel {
@ObservationIgnored
private let apiClient: APIClient
}
Severity: ๐ก Warning | Confidence: 85 | Rule: OB-031
Line 88: Accessing non-displayed property triggers view update
// BAD
var body: some View {
Text(viewModel.events.count) // Update on any events change
List(viewModel.events) { ... }
}
// GOOD
var body: some View {
List(viewModel.events) { ... } // Only this property accessed
}
Severity: ๐ก Warning | Confidence: 75 | Rule: OB-040
## Key References
- [Apple: Migrating from ObservableObject to Observable](https://developer.apple.com/documentation/SwiftUI/Migrating-from-the-observable-object-protocol-to-the-observable-macro)
- [Apple: Observation Framework](https://developer.apple.com/documentation/Observation)
- [Using @Observable in SwiftUI](https://nilcoalescing.com/blog/ObservableInSwiftUI/)
- [Observable Macro Performance Analysis](https://www.avanderlee.com/swiftui/observable-macro-performance-increase-observableobject/)
- [Modern MVVM with Observable (2025)](https://medium.com/@minalkewat/modern-mvvm-in-swiftui-2025-the-clean-architecture-youve-been-waiting-for-72a7d576648e)
- [Observation Framework Guide](https://jano.dev/apple/swiftui/2024/12/13/Observation-Framework.html)
- [Migration to Observable Patterns](https://useyourloaf.com/blog/migrating-to-observable/)