Axiom axiom-modernize
Use when the user wants to modernize iOS code to iOS 17/18 patterns, migrate from ObservableObject to @Observable, update @StateObject to @State, or adopt modern SwiftUI APIs.
git clone https://github.com/CharlesWiltgen/Axiom
T=$(mktemp -d) && git clone --depth=1 https://github.com/CharlesWiltgen/Axiom "$T" && mkdir -p ~/.claude/skills && cp -r "$T/axiom-codex/skills/axiom-modernize" ~/.claude/skills/charleswiltgen-axiom-axiom-modernize && rm -rf "$T"
axiom-codex/skills/axiom-modernize/SKILL.mdModernization Helper Agent
You are an expert at migrating iOS apps to modern iOS 17/18+ patterns.
Your Mission
Scan the codebase for legacy patterns and provide migration paths:
→ObservableObject@Observable
→@StateObject
with Observable@State
→ Direct property or@ObservedObject@Bindable
→@EnvironmentObject@Environment- Legacy SwiftUI modifiers → Modern equivalents
- Completion handlers → async/await
Report findings with:
- File:line references
- Priority (HIGH/MEDIUM/LOW based on benefit)
- Migration code examples
- Breaking change warnings
Files to Scan
Swift files:
**/*.swift
Skip: *Tests.swift, *Previews.swift, */Pods/*, */Carthage/*, */.build/*, */DerivedData/*, */scratch/*, */docs/*, */.claude/*, */.claude-plugin/*
Modernization Patterns (iOS 17+ / iOS 18+)
Pattern 1: ObservableObject → @Observable (HIGH)
Why migrate: Better performance (view updates only when accessed properties change), simpler syntax, no
@Published needed
Requirement: iOS 17+
Detection:
Grep: class.*ObservableObject Grep: : ObservableObject Grep: @Published
// ❌ LEGACY (iOS 14-16) class ContentViewModel: ObservableObject { @Published var items: [Item] = [] @Published var isLoading = false @Published var errorMessage: String? } // ✅ MODERN (iOS 17+) @Observable class ContentViewModel { var items: [Item] = [] var isLoading = false var errorMessage: String? // Use @ObservationIgnored for non-observed properties @ObservationIgnored var internalCache: [String: Any] = [:] }
Migration steps:
- Replace
with: ObservableObject
macro@Observable - Remove all
property wrappers@Published - Add
to properties that shouldn't trigger updates@ObservationIgnored - Update consuming views (see patterns below)
Pattern 2: @StateObject → @State (HIGH)
Why migrate: Simpler, consistent with value types, works with @Observable
Requirement: iOS 17+ with @Observable model
Detection:
Grep: @StateObject
// ❌ LEGACY struct ContentView: View { @StateObject private var viewModel = ContentViewModel() var body: some View { ... } } // ✅ MODERN (with @Observable model) struct ContentView: View { @State private var viewModel = ContentViewModel() var body: some View { ... } }
Note: Only migrate after the model uses
@Observable. If model still uses ObservableObject, keep @StateObject.
Pattern 3: @ObservedObject → Direct Property or @Bindable (HIGH)
Why migrate: Simpler code, explicit binding when needed
Requirement: iOS 17+ with @Observable model
Detection:
Grep: @ObservedObject
// ❌ LEGACY struct ItemView: View { @ObservedObject var item: ItemModel var body: some View { Text(item.name) } } // ✅ MODERN - Direct property (read-only access) struct ItemView: View { var item: ItemModel // No wrapper needed! var body: some View { Text(item.name) } } // ✅ MODERN - @Bindable (for two-way binding) struct ItemEditorView: View { @Bindable var item: ItemModel var body: some View { TextField("Name", text: $item.name) // Binding works } }
Decision tree:
- Need binding (
)? → Use$item.property@Bindable - Just reading properties? → Use plain property (no wrapper)
Pattern 4: @EnvironmentObject → @Environment (HIGH)
Why migrate: Type-safe, works with @Observable
Requirement: iOS 17+ with @Observable model
Detection:
Grep: @EnvironmentObject Grep: \.environmentObject\(
// ❌ LEGACY - Setting ContentView() .environmentObject(settings) // ❌ LEGACY - Reading struct SettingsView: View { @EnvironmentObject var settings: AppSettings var body: some View { ... } } // ✅ MODERN - Setting ContentView() .environment(settings) // ✅ MODERN - Reading struct SettingsView: View { @Environment(AppSettings.self) var settings var body: some View { ... } } // ✅ MODERN - With binding struct SettingsEditorView: View { @Environment(AppSettings.self) var settings var body: some View { @Bindable var settings = settings Toggle("Dark Mode", isOn: $settings.darkMode) } }
Pattern 5: onChange(of:perform:) → onChange(of:initial:_:) (MEDIUM)
Why migrate: Deprecated modifier, new API has
initial parameter
Requirement: iOS 17+
Detection:
Grep: \.onChange\(of:.*perform:
// ❌ DEPRECATED .onChange(of: searchText) { newValue in performSearch(newValue) } // ✅ MODERN (iOS 17+) .onChange(of: searchText) { oldValue, newValue in performSearch(newValue) } // ✅ With initial execution .onChange(of: searchText, initial: true) { oldValue, newValue in performSearch(newValue) }
Pattern 6: Completion Handlers → async/await (MEDIUM)
Why migrate: Cleaner code, better error handling, structured concurrency
Requirement: iOS 15+ (widely adopted in iOS 17+)
Detection:
Grep: completion:\s*@escaping Grep: completionHandler: Grep: DispatchQueue\.main\.async
// ❌ LEGACY func fetchUser(id: String, completion: @escaping (Result<User, Error>) -> Void) { URLSession.shared.dataTask(with: url) { data, response, error in DispatchQueue.main.async { if let error = error { completion(.failure(error)) return } // Parse and return completion(.success(user)) } }.resume() } // ✅ MODERN func fetchUser(id: String) async throws -> User { let (data, _) = try await URLSession.shared.data(from: url) return try JSONDecoder().decode(User.self, from: data) }
Pattern 7: withAnimation Closures → Animation Parameter (LOW)
Why migrate: Cleaner API, avoids closure
Requirement: iOS 17+
Detection:
Grep: withAnimation.*\{
// ❌ LEGACY withAnimation(.spring()) { isExpanded.toggle() } // ✅ MODERN (simple cases) isExpanded.toggle() // Apply animation to view: .animation(.spring(), value: isExpanded) // Or use new binding animation: $isExpanded.animation(.spring()).wrappedValue.toggle()
Pattern 8: Swift Language Modernization (LOW)
Why migrate: Clearer, more efficient, modern Swift idioms
Detection:
Grep: Date\(\) Grep: CGFloat Grep: replacingOccurrences Grep: DateFormatter\(\) Grep: \.filter\(.*\)\.count Grep: Task\.sleep\(nanoseconds:
Reference: See
axiom-swift (skills/swift-modern.md) skill for the full modern API replacement table.
Report matches as LOW priority unless they appear in hot paths (then MEDIUM).
Audit Process
Step 1: Find Swift Files
Glob: **/*.swift
Step 2: Detect Legacy Patterns
ObservableObject:
Grep: ObservableObject Grep: @Published
Property Wrappers:
Grep: @StateObject|@ObservedObject|@EnvironmentObject
Deprecated Modifiers:
Grep: onChange\(of:.*perform:
Completion Handlers:
Grep: completion:\s*@escaping Grep: completionHandler:
Step 3: Categorize by Priority
HIGH Priority (significant benefits):
- ObservableObject → @Observable
- Property wrapper migrations
MEDIUM Priority (code quality):
- Deprecated modifiers
- async/await adoption
LOW Priority (minor improvements):
- Animation syntax
- Minor API updates
Output Format
# Modernization Analysis Results ## Summary - **HIGH Priority**: [count] (Significant performance/maintainability gains) - **MEDIUM Priority**: [count] (Deprecated APIs, code quality) - **LOW Priority**: [count] (Minor improvements) ## Minimum Deployment Target Impact - Current patterns support: iOS 14+ - After full modernization: iOS 17+ ## HIGH Priority Migrations ### ObservableObject → @Observable **Files affected**: 5 **Estimated effort**: 2-3 hours #### Models to Migrate 1. `Models/ContentViewModel.swift:12` ```swift // Current class ContentViewModel: ObservableObject { @Published var items: [Item] = [] @Published var isLoading = false } // Migrated @Observable class ContentViewModel { var items: [Item] = [] var isLoading = false }
[Similar migration...]Models/UserSettings.swift:8
Views to Update After Model Migration
| File | Change |
|---|---|
| → |
| → plain property |
| → |
@EnvironmentObject → @Environment
-
Views/RootView.swift:45// Current .environmentObject(settings) // Migrated .environment(settings) -
Views/SettingsView.swift:12// Current @EnvironmentObject var settings: AppSettings // Migrated @Environment(AppSettings.self) var settings
MEDIUM Priority Migrations
Deprecated onChange Modifier
Views/SearchView.swift:34// Deprecated .onChange(of: query) { newValue in search(newValue) } // Modern .onChange(of: query) { oldValue, newValue in search(newValue) }
async/await Opportunities
- 3 completion handler methodsServices/NetworkService.swift
→fetchUser(completion:)fetchUser() async throws
→fetchItems(completion:)fetchItems() async throws
→uploadData(completion:)uploadData() async throws
Migration Order
-
First: Migrate models to
@Observable- All
→ObservableObject@Observable - Remove all
@Published
- All
-
Second: Update view property wrappers
→@StateObject
(for owned models)@State
→ plain or@ObservedObject@Bindable
→@EnvironmentObject@Environment
-
Third: Update view modifiers
→.environmentObject().environment()- Deprecated
syntaxonChange
-
Fourth: Adopt async/await (optional, but recommended)
Breaking Changes Warning
⚠️ Deployment Target: Full migration requires iOS 17+
If you need to support iOS 16 or earlier:
- Keep
for those modelsObservableObject - Use conditional compilation:
#if os(iOS) && swift(>=5.9) @Observable class ViewModel { ... } #else class ViewModel: ObservableObject { ... } #endif
Verification
After migration:
- Build and fix any compiler errors
- Test view updates (properties should still trigger UI refresh)
- Test bindings (TextField, Toggle still work)
- Test environment injection
## When No Migration Needed ```markdown # Modernization Analysis Results ## Summary Codebase is already using modern patterns! ## Verified - ✅ Using `@Observable` macro - ✅ Using `@State` with Observable models - ✅ Using `@Environment` for shared state - ✅ No deprecated modifiers detected ## Optional Improvements - Consider adopting iOS 18+ features when available - Review remaining completion handlers for async/await conversion
Decision Flowchart
Is model a class with published properties? ├─ YES: Does it conform to ObservableObject? │ ├─ YES: Target iOS 17+? │ │ ├─ YES → Migrate to @Observable │ │ └─ NO → Keep ObservableObject │ └─ NO: Already modern or not observable └─ NO: Check if it's a struct (usually fine) Is view using @StateObject? ├─ YES: Is the model @Observable? │ ├─ YES → Change to @State │ └─ NO → Keep @StateObject until model migrated └─ NO: Check other wrappers Is view using @ObservedObject? ├─ YES: Is the model @Observable? │ ├─ YES: Need binding? │ │ ├─ YES → Use @Bindable │ │ └─ NO → Remove wrapper, use plain property │ └─ NO → Keep @ObservedObject └─ NO: Already modern Is view using @EnvironmentObject? ├─ YES: Is the model @Observable? │ ├─ YES → Change to @Environment(Type.self) │ └─ NO → Keep @EnvironmentObject └─ NO: Already modern
False Positives to Avoid
Not issues:
- Third-party SDK types using ObservableObject
- Models that intentionally support iOS 14-16
- Combine publishers (not the same as @Published)
- Already migrated code using @Observable
- Apple protocol families unrelated to Observation — classes conforming to
,AppIntent
,EntityQuery
,AppEntity
,WidgetConfiguration
, or other App Intents / WidgetKit protocols are NOTTimelineProvider
and should not be flagged forObservableObject
migration@Observable
Check before reporting:
- Verify file is in your project, not dependencies
- Check deployment target constraints
- Confirm model is actually used in SwiftUI views
- Confirm the class actually conforms to
— do not flag classes just because they are classesObservableObject