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.

install
source · Clone the upstream repo
git clone https://github.com/CharlesWiltgen/Axiom
Claude Code · Install into ~/.claude/skills/
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"
manifest: axiom-codex/skills/axiom-modernize/SKILL.md
source content

Modernization 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
    @State
    with Observable
  • @ObservedObject
    → Direct property or
    @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:

  1. Replace
    : ObservableObject
    with
    @Observable
    macro
  2. Remove all
    @Published
    property wrappers
  3. Add
    @ObservationIgnored
    to properties that shouldn't trigger updates
  4. 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 (
    $item.property
    )? → Use
    @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
   }
  1. Models/UserSettings.swift:8
    [Similar migration...]

Views to Update After Model Migration

FileChange
Views/ContentView.swift:15
@StateObject
@State
Views/ItemList.swift:23
@ObservedObject
→ plain property
Views/SettingsView.swift:8
@EnvironmentObject
@Environment

@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

  • Services/NetworkService.swift
    - 3 completion handler methods
    • fetchUser(completion:)
      fetchUser() async throws
    • fetchItems(completion:)
      fetchItems() async throws
    • uploadData(completion:)
      uploadData() async throws

Migration Order

  1. First: Migrate models to

    @Observable

    • All
      ObservableObject
      @Observable
    • Remove all
      @Published
  2. Second: Update view property wrappers

    • @StateObject
      @State
      (for owned models)
    • @ObservedObject
      → plain or
      @Bindable
    • @EnvironmentObject
      @Environment
  3. Third: Update view modifiers

    • .environmentObject()
      .environment()
    • Deprecated
      onChange
      syntax
  4. 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
    ObservableObject
    for those models
  • Use conditional compilation:
    #if os(iOS) && swift(>=5.9)
    @Observable
    class ViewModel { ... }
    #else
    class ViewModel: ObservableObject { ... }
    #endif
    

Verification

After migration:

  1. Build and fix any compiler errors
  2. Test view updates (properties should still trigger UI refresh)
  3. Test bindings (TextField, Toggle still work)
  4. 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
    ,
    TimelineProvider
    , or other App Intents / WidgetKit protocols are NOT
    ObservableObject
    and should not be flagged for
    @Observable
    migration

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
    ObservableObject
    — do not flag classes just because they are classes