Skip to content
Back to skills

Swiftui Mvvm

ASecurity

Use this skill when working with SwiftUI ViewModels — creating, refactoring, or testing them. Triggers for: setting up a ViewModel for a SwiftUI screen, extracting logic from a View into a ViewModel, migrating from ObservableObject to @Observable, modeling async state (instead of separate Bool flags like isLoading/hasError), injecting dependencies into ViewModels, writing unit tests for @Observable ViewModels, NavigationStack/Router setup, or any question about SwiftUI app architecture. Also ...

  • 11 stars
  • 0 votes
  • 0 copies
  • 3 views
  • Added May 26, 2026
developmentgoswifttestingrefactoringapiperformance

Works with

  • cli
  • api

Security analysis

A100/100

Pro scans all 12 files and shows the line behind each finding

Scanned May 27, 2026

npx -y skills add rusel95/ios-agent-skills --skill swiftui-mvvm --agent claude-code

Installs into .claude/skills of the current project.

Are you the author of Swiftui Mvvm?

Add the live security badge to your README. It updates with every re-scan.

Security grade badge for Swiftui Mvvm
[![Security: A — Skills Directory](https://www.skillsdirectory.com/api/skills/rusel95-swiftui-mvvm/badge)](https://www.skillsdirectory.com/skills/rusel95-swiftui-mvvm)

More formats (shields.io, HTML) on the badges page. Keep it an A: scan every change in CI with Pro.

Download with Pro
SKILL.md
---
name: swiftui-mvvm
description: "Use this skill when working with SwiftUI ViewModels — creating, refactoring, or testing them. Triggers for: setting up a ViewModel for a SwiftUI screen, extracting logic from a View into a ViewModel, migrating from ObservableObject to @Observable, modeling async state (instead of separate Bool flags like isLoading/hasError), injecting dependencies into ViewModels, writing unit tests for @Observable ViewModels, NavigationStack/Router setup, or any question about SwiftUI app architecture. Also use when a SwiftUI View imports too much business logic, when someone asks how to structure a SwiftUI screen 'the modern way,' or when they ask about @State/@Bindable ownership, ViewState patterns, or why their ViewModel shouldn't import SwiftUI."
metadata:
  version: 1.0.4
---

> **Approach: Production-First Iterative Refactoring** — This skill is built for production enterprise codebases where stability and reviewability matter more than speed. Architecture changes are delivered through iterative refactoring — small, focused PRs (≤200 lines, single concern) tracked in a `refactoring/` directory. Critical safety issues ship first; cosmetic improvements come last.

# SwiftUI MVVM Architecture (iOS 17+)

Enterprise-grade SwiftUI MVVM architecture skill. Opinionated: prescribes @Observable ViewModels, Router navigation, constructor injection, ViewState enum, and Repository-based networking. Adopts a **production-first iterative refactoring** approach — every pattern is chosen for testability, reviewability, and safe incremental adoption in large teams. For non-architectural SwiftUI API guidance (animations, modern API replacements, Liquid Glass), use a general SwiftUI skill instead.

## Architecture Layers

```
View Layer         → SwiftUI Views. Declarative UI only. Owns ViewModel via @State.
ViewModel Layer    → @Observable @MainActor final class. Exposes ViewState<T>.
Repository Layer   → Protocol-based data access. Hides data source details.
Service Layer      → URLSession, persistence. Injected via protocol.
```

## Quick Decision Trees

### "Should this View have a ViewModel?"

```
Is there business logic, networking, or complex state?
├── YES → Create @Observable ViewModel
└── NO → Is it a reusable UI component (button, card, cell)?
    ├── YES → Plain struct with data parameters, NO ViewModel
    └── NO → No ViewModel needed unless it simplifies testing
```

### "How should I own this ViewModel?"

```
Does THIS view create the ViewModel?
├── YES → @State private var viewModel = MyViewModel()
└── NO → Does the view need $ bindings to ViewModel properties?
    ├── YES → @Bindable var viewModel: MyViewModel
    └── NO → let viewModel: MyViewModel (plain property)
```

### "Where do dependencies come from?"

```
ViewModel always receives dependencies via constructor:
  init(repository: ItemRepositoryProtocol)

How does the View get the dependency to pass?
├── Shared service (used across many screens)
│   └── Register via @Entry in EnvironmentValues
│       View reads @Environment(\.repo), passes to VM init
├── Screen-specific dependency (passed by parent)
│   └── View receives it as init parameter, passes to VM init
└── Outside view hierarchy (background service, deep utility)
    └── @Injected property wrapper (legacy/convenience only)
```

## Workflows

> **Default workflow**: Analyze & Refactor (below). New screen creation applies the same patterns but from a clean slate. In production enterprise codebases, most work is iterative modernization — not greenfield.

### Workflow: Analyze & Refactor Existing Codebase

**When:** First encounter with a legacy SwiftUI codebase — the most common enterprise scenario.

1. Scan for anti-patterns using the detection checklist (`references/anti-patterns.md`)
2. Create `refactoring/` directory with per-feature plan files (`references/refactoring-workflow.md`)
3. Write each issue with **full description** (Location, Severity, Problem, Fix) — titles alone get forgotten
4. Categorize issues by severity: 🔴 Critical → 🟡 High → 🟢 Medium
5. Plan Phase 1 PR: fix critical safety issues only (≤200 lines per PR)
6. Execute one PR at a time. New findings go to `refactoring/discovered.md` with full descriptions, NOT into current PR
7. After completing each fix: mark the task `- [x]` in the feature file and update `refactoring/README.md` progress table
8. Proceed through phases: Critical → @Observable migration → ViewState → Architecture

### Workflow: Create a New Screen

**When:** Building a new feature screen from scratch. Apply enterprise patterns from the start so no refactoring is needed later.

1. Define the data model and repository protocol (`references/networking.md`)
2. Create ViewModel: `@Observable @MainActor final class` with `ViewState<T>` (`references/mvvm-observable.md`)
3. Add `// MARK: -` sections: Properties, Init, Actions, Computed Properties
4. Create the screen View with `@State private var viewModel`
5. Wire data loading via `.task { await viewModel.load() }`
6. Add navigation route to Router enum (`references/navigation.md`)
7. Register dependencies in `@Environment` or `@Injected` (`references/dependency-injection.md`)
8. Create test file with mock repository (`references/testing.md`)

### Workflow: Migrate ViewModel from ObservableObject

**When:** Modernizing existing code from Combine-based observation to @Observable.

1. Add `Self._printChanges()` to the View body — note current redraw triggers
2. Replace `ObservableObject` conformance with `@Observable` macro
3. Remove all `@Published` — plain `var` properties are auto-tracked
4. Replace `@StateObject` with `@State` in the owning View
5. Replace `@ObservedObject` with plain `let` (or `@Bindable` if `$` bindings needed)
6. Replace `@EnvironmentObject` with `@Environment(Type.self)`
7. Add `@MainActor` to the ViewModel class declaration
8. Verify with `Self._printChanges()` — confirm fewer/more specific redraw triggers
9. Run existing tests — all must pass
10. Remove `Self._printChanges()` before committing

## Code Generation Rules

<critical_rules>
Whether generating new code or refactoring existing code, every output must be **production-ready and PR-shippable** — small, focused, and testable. ALWAYS:

1. Mark ViewModels as `@Observable @MainActor final class`
2. Use `private(set) var` for state properties modified only by the ViewModel
3. Use `ViewState<T>` enum for async data — never separate boolean flags
4. Inject dependencies via constructor with protocol types
5. Use `.task { }` for initial data loading
6. Keep View bodies pure — no `Task { }` inside body, no business logic
7. Use typed `enum Route: Hashable` for navigation
8. Add `// MARK: -` sections: Properties, Init, Actions, Computed Properties
9. Import only `Foundation` (and domain modules) in ViewModels — never `SwiftUI`
10. Keep every generated file ≤ 400 lines. Extract subviews into dedicated files. Split ViewModel logic into extensions (`MyVM+Search.swift`) or child ViewModels when approaching that limit. For legacy files, log a split task in the feature's `refactoring/` plan instead of forcing it mid-refactor.
11. Before modifying a View or ViewModel, output a brief `<thought>` analyzing its current state and redraw triggers.
12. **`@Observable` instances passed to `.environment(...)` must be owned by a stable `@State`** — `.environment(AppTheme())` creates a new `AppTheme` on every parent redraw, which defeats the point of environment injection and breaks observation. Always own it: `@State private var theme = AppTheme()` then `.environment(theme)`.
13. **ViewModel signals navigation intent via closures; View owns the `@State` toggle.** Instead of `viewModel.router.push(.profile(user))`, the ViewModel exposes `var onOpenProfile: ((User) -> Void)?` and the View sets `.onOpenProfile = { user in path.append(.profile(user)) }` at wire-up time. This keeps the ViewModel ignorant of navigation mechanics (testable, reusable across hosts) while the View remains the single owner of `@State path`.
14. **`Self._printChanges()` verification steps for splitting views.** When fixing redraw storms, verification is a sequence, not a single check: (1) add `let _ = Self._printChanges()` to both the parent view body AND the split child, (2) type/tap to produce change events, (3) confirm ONLY the target child prints during the interaction — if the parent also prints, it has a hidden read of the changing value (search the body for every `store.property` access and verify equality-only reads use stored, not computed, state), (4) remove both `_printChanges` before committing.
</critical_rules>

## Anti-Pattern Severity Reference

When auditing a SwiftUI codebase, assign severities from this canonical table. Models systematically mis-classify these — memorize the boundaries.

| Anti-pattern | Severity | Why this level |
|---|:---:|---|
| `import SwiftUI` in a ViewModel | 🔴 Critical | ViewModel leaks View-layer types; makes the ViewModel untestable outside a SwiftUI host |
| `UIViewController`/`UIView` reference inside a ViewModel | 🔴 Critical | Same — hard coupling to UIKit, blocks cross-platform and blocks unit testing |
| Missing `@MainActor` on a ViewModel that mutates UI-facing state | 🔴 Critical | Undefined behavior — UI reads off-main-thread state, potential crashes |
| Force-unwrap `!` on an async result in a ViewModel | 🔴 Critical | Crash vector in production |
| `viewModel` declared as plain `var` without `@State` in the owning View | 🟡 High | A new ViewModel instance is created on every parent redraw — loses all state, fires effects repeatedly |
| `.onAppear { Task { await viewModel.load() } }` | 🟡 High | Unmanaged: fires on every view appearance (back-nav, tab switch), not tied to task lifetime. Replace with `.task { }` |
| Business logic in View body (network calls, mutation) | 🟡 High | Breaks reviewability and testability, but not a crash |
| Separate `isLoading` / `error` / `data` boolean flags on a ViewModel | 🟢 Medium | Functionally works but creates impossible states (`isLoading && error != nil`) — migrate to `ViewState<T>` enum |
| Missing `// MARK: -` section comments | 🟢 Medium | Cosmetic, affects reviewability |
| `@StateObject` / `@ObservedObject` / `@EnvironmentObject` in an `@Observable` migration path | 🟢 Medium | Works with legacy `ObservableObject` but should be migrated in phased PRs |

**Decision rules:**
- **Critical** = crash, data corruption, or blocks all testing
- **High** = not a crash, but breaks an architectural invariant that cascades (unmanaged tasks, lost state, hidden coupling)
- **Medium** = functional code with a long-tail maintenance cost or impossible states

## Migration Mapping: `ObservableObject` → `@Observable`

This table must appear verbatim in every "migrate from ObservableObject" response — readers copy it into code reviews.

| Legacy (`ObservableObject`) | Modern (`@Observable`) | Note |
|---|---|---|
| `class VM: ObservableObject` | `@Observable class VM` | Add `@MainActor final` if it mutates UI state |
| `@Published var count` | `var count` | Plain `var` — the macro auto-tracks reads |
| `@StateObject var vm = VM()` in the owner | `@State private var vm = VM()` | `@State` owns the lifecycle; same semantics |
| `@ObservedObject var vm: VM` in a child that only reads | `let vm: VM` | No wrapper — plain reference, tracked automatically |
| `@ObservedObject var vm: VM` in a child that needs `$vm.property` | `@Bindable var vm: VM` | `@Bindable` enables two-way bindings without ownership |
| `@EnvironmentObject var theme: AppTheme` | `@Environment(AppTheme.self) private var theme` | Type-based lookup; env value must be registered via `.environment(themeInstance)` |
| `ObservableObject` + `@Published` tests assert on publisher changes | Reads to properties inside `withObservationTracking { }` | Modern Observation framework replaces Combine plumbing |

**Common gotcha:** models often apply `@State` to the VM in the owning view but forget to switch `@ObservedObject` → `let` or `@Bindable` in child views — the whole chain must migrate together or child views won't observe changes.

When generating tests, ALWAYS:

1. Use protocol mocks with `var stubbed*` and `var *CallCount` tracking
2. Test through public interface, never test private methods
3. Mark test classes/structs `@MainActor` when testing `@MainActor` ViewModels
4. Use `await fulfillment(of:)` for async tests — NEVER `wait(for:)` (deadlocks)
5. Include memory leak detection with `addTeardownBlock { [weak sut] in XCTAssertNil(sut) }`

## Fallback Strategies & Loop Breakers

<fallback_strategies>
When refactoring legacy code, you may encounter stubborn Swift compiler errors. If you fail to fix the same error twice, break the loop:

1. **@State vs @Bindable Generics:** If the compiler complains about property wrapper bindings (`$`), ensure you use `@Bindable` in subviews for `@Observable` types. Why: `@State` creates ownership (single source of truth), while `@Bindable` enables two-way bindings without ownership — the compiler enforces this distinction. If unresolved, temporarily use plain `let` and closure callbacks to unblock compilation.
2. **NavigationStack Path Issues:** If the compiler complains about `Hashable` routes or `navigationDestination` types, ensure your `enum Route` is perfectly `Hashable` and avoid passing complex models (prefer passing IDs). Why: NavigationStack serializes the path for state restoration, so every route case must be deterministically hashable.
3. **Revert and Restart:** If a View refactor spirals into 50+ compiler errors related to ambiguous type inference, stop. Propose reverting the changes and breaking the problem into two smaller phases (e.g. migrate properties first, then extract subviews). Why: SwiftUI's type inference cascades — a single change can destabilize unrelated code, and small PRs are far easier to review and debug.
</fallback_strategies>

## Confidence Checks

Before finalizing generated or refactored code, verify ALL:

```
□ No duplicate functionality — searched codebase for existing implementations
□ Architecture adherence — follows patterns already established in the project
□ Naming conventions — matches existing project naming style
□ Import check — ViewModel imports only Foundation, NOT SwiftUI
□ @MainActor — present on all ViewModel class declarations
□ ViewState — used for all async data, no separate isLoading/error booleans
□ DI — dependencies injected via protocol, not accessed via singletons
□ Task management — .task modifier for lifecycle, explicit cancellation handling
□ CancellationError — handled silently, never shown to user
□ Tests — corresponding test file exists or is created alongside
□ PR scope — changes within defined scope, new findings go to `refactoring/discovered.md`
□ File size — new files ≤ 400 lines; existing oversized files have a split task logged in `refactoring/`
```

## Companion Skills

> **Before generating async ViewModel, Task, or actor code:** determine the project's concurrency approach. If unclear from context, ask the user.

| Project's concurrency stack | Companion skill | Apply when |
|---|---|---|
| `async/await`, actors, Swift 6, `@MainActor` | `skills/swift-concurrency/SKILL.md` | Writing async ViewModel methods, Task creation, actor-isolated state |
| `DispatchQueue`, `OperationQueue` (legacy or hybrid) | `skills/gcd-operations/SKILL.md` | Writing queue-based networking, background work, thread-safe state |

**If unclear, ask:** "Does this project use Swift Concurrency (async/await) or GCD for async operations?"

## References

| Reference | When to Read |
|-----------|-------------|
| `references/rules.md` | Do's and Don'ts quick reference: priority rules and critical anti-patterns |
| `references/mvvm-observable.md` | Creating ViewModels, @State/@Bindable ownership rules, migration mapping |
| `references/navigation.md` | Router pattern, deep linking, TabView setup, sheets |
| `references/dependency-injection.md` | @Environment, @Injected wrapper, constructor injection, testing DI |
| `references/networking.md` | ViewState enum, Repository pattern, HTTPClient, task cancellation |
| `references/anti-patterns.md` | Code review detection checklist, severity-ranked violations |
| `references/testing.md` | ViewModel unit tests, async patterns, mocks, memory leak detection |
| `references/performance.md` | Self._printChanges(), Instruments, launch time, verification evidence |
| `references/file-organization.md` | File size guidelines, extension splitting, child ViewModels, subview extraction |
| `references/refactoring-workflow.md` | `refactoring/` directory protocol, per-feature plans, PR sizing, phase ordering |

Files in this skill

  • README.md7.6 KB
  • SKILL.md16.6 KB
  • references/anti-patterns.md7.7 KB
  • references/dependency-injection.md6.5 KB
  • references/file-organization.md7.2 KB
  • references/mvvm-observable.md6.9 KB
  • references/navigation.md4.8 KB
  • references/networking.md4.9 KB
  • references/performance.md7.1 KB
  • references/refactoring-workflow.md13 KB
  • references/rules.md2.8 KB
  • references/testing.md4.7 KB

Attribution

Is this your skill, or is something wrong with this listing? Request removal or report an issue. Author removals are honored within 72 hours.

Comments

Loading comments…

Newsletter

Get the weekly list of safe new skills, and the ones we pulled.