Generate FOSMVVM ViewModels - the bridge between server-side data and client-side Views. Use when creating new screens, pages, components, or any UI that displays data.
/plugin marketplace add foscomputerservices/FOSUtilities/plugin install fosmvvm-generators@fosmvvm-toolsThis skill inherits all available tools. When active, it can use any tool Claude has access to.
Generate ViewModels following FOSMVVM architecture patterns.
For full architecture context, see FOSMVVMArchitecture.md
A ViewModel is the bridge in the Model-View-ViewModel architecture:
┌─────────────┐ ┌─────────────────┐ ┌─────────────┐
│ Model │ ───► │ ViewModel │ ───► │ View │
│ (Data) │ │ (The Bridge) │ │ (SwiftUI) │
└─────────────┘ └─────────────────┘ └─────────────┘
Key insight: In FOSMVVM, ViewModels are:
@LocalizedString references)This is a per-ViewModel decision. An app can mix both modes - for example, a standalone iPhone app with server-based sign-in.
Ask: Where does THIS ViewModel's data come from?
| Data Source | Hosting Mode | Factory |
|---|---|---|
| Server/Database | Server-Hosted | Hand-written |
| Local state/preferences | Client-Hosted | Macro-generated |
When data comes from a server:
ViewModelFactory protocol)Examples: Sign-in screen, user profile from API, dashboard with server data
When data is local to the device:
@ViewModel(options: [.clientHostedFactory])Examples: Settings screen, onboarding, offline-first features
Many apps use both:
┌─────────────────────────────────────────┐
│ iPhone App │
├─────────────────────────────────────────┤
│ SettingsViewModel → Client-Hosted │
│ OnboardingViewModel → Client-Hosted │
│ SignInViewModel → Server-Hosted │
│ UserProfileViewModel → Server-Hosted │
└─────────────────────────────────────────┘
Same ViewModel patterns work in both modes - only the factory creation differs.
A ViewModel's job is shaping data for presentation. This happens in two places:
The View just renders - it should never compose, format, or reorder ViewModel properties.
A ViewModel answers: "What does the View need to display?"
| Content Type | How It's Represented | Example |
|---|---|---|
| Static UI text | @LocalizedString | Page titles, button labels |
| Dynamic data in text | @LocalizedSubs | "Welcome, %{name}!" with substitutions |
| Composed text | @LocalizedCompoundString | Full name from pieces (locale-aware order) |
| Formatted dates | LocalizableDate | createdAt: LocalizableDate |
| Formatted numbers | LocalizableInt | totalCount: LocalizableInt |
| Dynamic data | Plain properties | content: String, count: Int |
| Nested components | Child ViewModels | cards: [CardViewModel] |
@Parent, @Siblings)// ❌ WRONG - View is composing
Text(viewModel.firstName) + Text(" ") + Text(viewModel.lastName)
// ✅ RIGHT - ViewModel provides shaped result
Text(viewModel.fullName) // via @LocalizedCompoundString
If you see + or string interpolation in a View, the shaping belongs in the ViewModel.
public protocol ViewModel: ServerRequestBody, RetrievablePropertyNames, Identifiable, Stubbable {
var vmId: ViewModelId { get }
}
public protocol RequestableViewModel: ViewModel {
associatedtype Request: ViewModelRequest
}
ViewModel provides:
ServerRequestBody - Can be sent over HTTP as JSONRetrievablePropertyNames - Enables @LocalizedString binding (via @ViewModel macro)Identifiable - Has vmId for SwiftUI identityStubbable - Has stub() for testing/previewsRequestableViewModel adds:
Request type for fetching from serverRepresents a full page or screen. Has:
ViewModelRequest typeViewModelFactory that builds it from database@ViewModel
public struct DashboardViewModel: RequestableViewModel {
public typealias Request = DashboardRequest
@LocalizedString public var pageTitle
public let cards: [CardViewModel] // Children
public var vmId: ViewModelId = .init()
}
Nested components built by their parent's factory. No Request type.
@ViewModel
public struct CardViewModel: Codable, Sendable {
public let id: ModelIdType
public let title: String
public let createdAt: LocalizableDate
public var vmId: ViewModelId = .init()
}
ViewModels serve two distinct purposes:
| Purpose | ViewModel Type | Adopts Fields? |
|---|---|---|
| Display data (read-only) | Display ViewModel | No |
| Collect user input (editable) | Form ViewModel | Yes |
For showing data - cards, rows, lists, detail views:
@ViewModel
public struct UserCardViewModel {
public let id: ModelIdType
public let name: String
@LocalizedString public var roleDisplayName
public let createdAt: LocalizableDate
public var vmId: ViewModelId = .init()
}
Characteristics:
let (read-only)For collecting input - create forms, edit forms, settings:
@ViewModel
public struct UserFormViewModel: UserFields { // ← Adopts Fields!
public var id: ModelIdType?
public var email: String
public var firstName: String
public var lastName: String
public let userValidationMessages: UserFieldsMessages
public var vmId: ViewModelId = .init()
}
Characteristics:
var (editable)┌─────────────────────────────────────────────────────────────────┐
│ UserFields Protocol │
│ (defines editable properties + validation) │
│ │
│ Adopted by: │
│ ┌─────────────────┐ ┌─────────────────┐ ┌─────────────────┐ │
│ │ CreateUserReq │ │ UserFormVM │ │ User (Model) │ │
│ │ .RequestBody │ │ (UI form) │ │ (persistence) │ │
│ └─────────────────┘ └─────────────────┘ └─────────────────┘ │
│ │
│ Same validation logic everywhere! │
└─────────────────────────────────────────────────────────────────┘
Ask: "Is the user editing data in this ViewModel?"
| ViewModel | User Edits? | Adopt Fields? |
|---|---|---|
UserCardViewModel | No | No |
UserRowViewModel | No | No |
UserDetailViewModel | No | No |
UserFormViewModel | Yes | UserFields |
CreateUserViewModel | Yes | UserFields |
EditUserViewModel | Yes | UserFields |
SettingsViewModel | Yes | SettingsFields |
| File | Location | Purpose |
|---|---|---|
{Name}ViewModel.swift | {ViewModelsTarget}/ | The ViewModel struct |
{Name}Request.swift | {ViewModelsTarget}/ | The ViewModelRequest type |
{Name}ViewModel.yml | {ResourcesPath}/ | Localization strings |
{Name}ViewModel+Factory.swift | {WebServerTarget}/ | Factory that builds from DB |
| File | Location | Purpose |
|---|---|---|
{Name}ViewModel.swift | {ViewModelsTarget}/ | ViewModel with clientHostedFactory option |
{Name}ViewModel.yml | {ResourcesPath}/ | Localization strings (bundled in app) |
No Request or Factory files needed - macro generates them!
| File | Location | Purpose |
|---|---|---|
{Name}ViewModel.swift | {ViewModelsTarget}/ | The ViewModel struct |
{Name}ViewModel.yml | {ResourcesPath}/ | Localization (if has @LocalizedString) |
| Placeholder | Description | Example |
|---|---|---|
{ViewModelsTarget} | Shared ViewModels SPM target | ViewModels |
{ResourcesPath} | Localization resources | Sources/Resources |
{WebServerTarget} | Server-side target | WebServer, AppServer |
Ask: Where does this ViewModel's data come from?
Clarify:
Determine:
@LocalizedString?vmId = .init(type: Self.self)) or instance (vmId = .init(id: id))?Server-Hosted Top-Level:
RequestableViewModel)Client-Hosted Top-Level:
clientHostedFactory option)Child (either mode):
Always use the @ViewModel macro - it generates the propertyNames() method required for localization binding.
Server-Hosted (basic macro):
@ViewModel
public struct MyViewModel: RequestableViewModel {
public typealias Request = MyRequest
@LocalizedString public var title
public var vmId: ViewModelId = .init()
public init() {}
}
Client-Hosted (with factory generation):
@ViewModel(options: [.clientHostedFactory])
public struct SettingsViewModel {
@LocalizedString public var pageTitle
public var vmId: ViewModelId = .init()
public init(theme: Theme, notifications: NotificationSettings) {
// Init parameters become AppState properties
}
}
// Macro auto-generates:
// - typealias Request = ClientHostedRequest
// - struct AppState { let theme: Theme; let notifications: NotificationSettings }
// - class ClientHostedRequest: ViewModelRequest { ... }
// - static func model(context:) async throws -> Self { ... }
All ViewModels must support stub() for testing and SwiftUI previews:
public extension MyViewModel {
static func stub() -> Self {
.init(/* default values */)
}
}
Every ViewModel needs a vmId for SwiftUI's identity system:
Singleton (one per page): vmId = .init(type: Self.self)
Instance (multiple per page): vmId = .init(id: id) where id: ModelIdType
Static UI text uses @LocalizedString:
@LocalizedString public var pageTitle
With corresponding YAML:
en:
MyViewModel:
pageTitle: "Welcome"
Never send pre-formatted strings. Use localizable types:
public let createdAt: LocalizableDate // NOT String
public let itemCount: LocalizableInt // NOT String
The client formats these according to user's locale and timezone.
Top-level ViewModels contain their children:
@ViewModel
public struct BoardViewModel: RequestableViewModel {
public let columns: [ColumnViewModel]
public let cards: [CardViewModel]
}
The Factory builds all children when building the parent.
Swift's synthesized Codable only encodes stored properties. Since ViewModels are serialized (for JSON transport, Leaf rendering, etc.), computed properties won't be available.
// Computed - NOT encoded, invisible after serialization
public var hasCards: Bool { !cards.isEmpty }
// Stored - encoded, available after serialization
public let hasCards: Bool
When to pre-compute:
For Leaf templates, you can often use Leaf's built-in functions directly:
#if(count(cards) > 0) - no need for hasCards property#count(cards) - no need for cardCount propertyPre-compute only when:
firstCard - array indexing not documented in Leaf)See fosmvvm-leaf-view-generator for Leaf template patterns.
See reference.md for complete file templates.
| Concept | Convention | Example |
|---|---|---|
| ViewModel struct | {Name}ViewModel | DashboardViewModel |
| Request class | {Name}Request | DashboardRequest |
| Factory extension | {Name}ViewModel+Factory.swift | DashboardViewModel+Factory.swift |
| YAML file | {Name}ViewModel.yml | DashboardViewModel.yml |
| Version | Date | Changes |
|---|---|---|
| 1.0 | 2024-12-24 | Initial skill |
| 2.0 | 2024-12-26 | Complete rewrite from architecture; generalized from Kairos-specific |
| 2.1 | 2024-12-26 | Added Client-Hosted mode support; per-ViewModel hosting decision |
| 2.2 | 2024-12-26 | Added shaping responsibility, @LocalizedSubs/@LocalizedCompoundString, anti-pattern |
| 2.3 | 2025-12-27 | Added Display vs Form ViewModels section; clarified Fields adoption |
| 2.4 | 2026-01-08 | Added Codable/computed properties section. Clarified when to pre-compute vs use Leaf built-ins. |
Use when working with Payload CMS projects (payload.config.ts, collections, fields, hooks, access control, Payload API). Use when debugging validation errors, security issues, relationship queries, transactions, or hook behavior.