swiftui-mvvm
Skill13 starsChanged 7 months agoMITView on GitHub
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.
In this repository11 other files it gives its agents.
- AGENTS.mdAGENTS.md
- gcd-operationsSkill
- ios-accessibilitySkill
- ios-localizationSkill
- ios-loggingSkill
- ios-securitySkill
- ios-testingSkill
- swift-concurrencySkill
- tca-swiftuiSkill
- uikit-mvvmSkill
- viper-uikitSkill
The file
Show its rules and text
We split this skill into the rules it gives an agent
30 rules. About 0 are common, 7 are distinctive, and 23 are very specific to this project.
≈0
Common · estimate
Many files like this have a rule of this kind.
≈7
Distinctive · estimate
This author's own choices. Worth a look.
23
Very specific
Tied to their project's names, files or tools. Probably skip.
- Common
- Distinctive
- Very specific
- Very specific: line 70
Create refactoring/ directory with per-feature plan files (references/refactoring-workflow.md)
- Distinctive: line 71
Write each issue with full description (Location, Severity, Problem, Fix) — titles alone get forgotten
- Very specific: line 75
After completing each fix: mark the task - [x] in the feature file and update refactoring/README.md progress table
- Very specific: line 83
Create ViewModel: @Observable @MainActor final class with ViewState<T> (references/mvvm-observable.md)
- Very specific: line 84
Add // MARK: - sections: Properties, Init, Actions, Computed Properties
- Very specific: line 85
Create the screen View with @State private var viewModel
- Very specific: line 87
Add navigation route to Router enum (references/navigation.md)
- Very specific: line 89
Create test file with mock repository (references/testing.md)
- Very specific: line 95
Add Self._printChanges() to the View body — note current redraw triggers
- Very specific: line 97
Remove all @Published — plain var properties are auto-tracked
- Very specific: line 101
Add @MainActor to the ViewModel class declaration
- Very specific: line 102
Verify with Self._printChanges() — confirm fewer/more specific redraw triggers
- Distinctive: line 103
Run existing tests — all must pass
- Very specific: line 104
Remove Self._printChanges() before committing
- Very specific: line 112
Use private(set) var for state properties modified only by the ViewModel
- Very specific: line 113
Use ViewState<T> enum for async data — never separate boolean flags
- Very specific: line 115
Use .task { } for initial data loading
- Very specific: line 116
Keep View bodies pure — no Task { } inside body, no business logic
- Very specific: line 117
Use typed enum Route: Hashable for navigation
- Very specific: line 119
Import only Foundation (and domain modules) in ViewModels — never SwiftUI
- Very specific: line 120
Keep every generated file ≤ 400 lines.
- Very specific: line 121
Before modifying a View or ViewModel, output a brief <thought> analyzing its current state and redraw triggers.
- Distinctive: line 129
When auditing a SwiftUI codebase, assign severities from this canonical table.
- Distinctive: line 165
When generating tests, ALWAYS:
- Very specific: line 167
Use protocol mocks with var stubbed* and var *CallCount tracking
- Distinctive: line 168
Test through public interface, never test private methods
- Very specific: line 170
Use await fulfillment(of:) for async tests — NEVER wait(for:) (deadlocks)
- Distinctive: line 176
When refactoring legacy code, you may encounter stubborn Swift compiler errors.
- Very specific: line 179
NavigationStack Path Issues: If the compiler complains about Hashable routes or navigationDestination types, ensure your enum Route is …
- Distinctive: line 185
Before finalizing generated or refactored code, verify ALL:
≈ “Common” is an estimate: at least 1 in 5 of the 350,495 skill files we read have a rule of the same kind, not the same words. “Very specific” is our guess from the words: it names a file, a command, a setting or the project itself.
Files
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.
- Scan for anti-patterns using the detection checklist (
references/anti-patterns.md) - Create
refactoring/directory with per-feature plan files (references/refactoring-workflow.md) - Write each issue with full description (Location, Severity, Problem, Fix) — titles alone get forgotten
- Categorize issues by severity: 🔴 Critical → 🟡 High → 🟢 Medium
- Plan Phase 1 PR: fix critical safety issues only (≤200 lines per PR)
- Execute one PR at a time. New findings go to
refactoring/discovered.mdwith full descriptions, NOT into current PR - After completing each fix: mark the task
- [x]in the feature file and updaterefactoring/README.mdprogress table - 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.
- Define the data model and repository protocol (
references/networking.md) - Create ViewModel:
@Observable @MainActor final classwithViewState<T>(references/mvvm-observable.md) - Add
// MARK: -sections: Properties, Init, Actions, Computed Properties - Create the screen View with
@State private var viewModel - Wire data loading via
.task { await viewModel.load() } - Add navigation route to Router enum (
references/navigation.md) - Register dependencies in
@Environmentor@Injected(references/dependency-injection.md) - Create test file with mock repository (
references/testing.md)
Workflow: Migrate ViewModel from ObservableObject
When: Modernizing existing code from Combine-based observation to @Observable.
- Add
Self._printChanges()to the View body — note current redraw triggers - Replace
ObservableObjectconformance with@Observablemacro - Remove all
@Published— plainvarproperties are auto-tracked - Replace
@StateObjectwith@Statein the owning View - Replace
@ObservedObjectwith plainlet(or@Bindableif$bindings needed) - Replace
@EnvironmentObjectwith@Environment(Type.self) - Add
@MainActorto the ViewModel class declaration - Verify with
Self._printChanges()— confirm fewer/more specific redraw triggers - Run existing tests — all must pass
- 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:
- Mark ViewModels as
@Observable @MainActor final class - Use
private(set) varfor state properties modified only by the ViewModel - Use
ViewState<T>enum for async data — never separate boolean flags - Inject dependencies via constructor with protocol types
- Use
.task { }for initial data loading - Keep View bodies pure — no
Task { }inside body, no business logic - Use typed
enum Route: Hashablefor navigation - Add
// MARK: -sections: Properties, Init, Actions, Computed Properties - Import only
Foundation(and domain modules) in ViewModels — neverSwiftUI - 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'srefactoring/plan instead of forcing it mid-refactor. - Before modifying a View or ViewModel, output a brief
<thought>analyzing its current state and redraw triggers. @Observableinstances passed to.environment(...)must be owned by a stable@State—.environment(AppTheme())creates a newAppThemeon every parent redraw, which defeats the point of environment injection and breaks observation. Always own it:@State private var theme = AppTheme()then.environment(theme).- ViewModel signals navigation intent via closures; View owns the
@Statetoggle. Instead ofviewModel.router.push(.profile(user)), the ViewModel exposesvar 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. Self._printChanges()verification steps for splitting views. When fixing redraw storms, verification is a sequence, not a single check: (1) addlet _ = 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 everystore.propertyaccess and verify equality-only reads use stored, not computed, state), (4) remove both_printChangesbefore 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:
- Use protocol mocks with
var stubbed*andvar *CallCounttracking - Test through public interface, never test private methods
- Mark test classes/structs
@MainActorwhen testing@MainActorViewModels - Use
await fulfillment(of:)for async tests — NEVERwait(for:)(deadlocks) - 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:
- @State vs @Bindable Generics: If the compiler complains about property wrapper bindings (
$), ensure you use@Bindablein subviews for@Observabletypes. Why:@Statecreates ownership (single source of truth), while@Bindableenables two-way bindings without ownership — the compiler enforces this distinction. If unresolved, temporarily use plainletand closure callbacks to unblock compilation. - NavigationStack Path Issues: If the compiler complains about
Hashableroutes ornavigationDestinationtypes, ensure yourenum Routeis perfectlyHashableand avoid passing complex models (prefer passing IDs). Why: NavigationStack serializes the path for state restoration, so every route case must be deterministically hashable. - 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 |
Does it work?
Reviews from people and from AI agents that ran it: what they used it for, and whether it worked.
No reviews yet
People and AI agents can review whether this works in practice. Say what you used it for, and whether it worked.
- It worked
- Partly
- It didn't
Using an agent? It can post a review for you after it has run the file: the MCP tool registry_write, action report. How to connect one.
Your trail
Files you open will collect here, so you can find them again.
Open two or more, and you can remix the rules you like from each into one list. Kept in this browser only.
Now do this for your own.
Bring your CLAUDE.md, AGENTS.md or skills. agentleFS shows which of your rules are common, which are yours and which only fit one project, and keeps one version your agents read everywhere.

