VSM is a reactive architecture that is unidirectional, highly type-safe, behavior-driven, and clean. This repository hosts an open-source Swift package for building features in VSM on Apple platforms.
VSM stands for both “View State Model” and “Viewable State Machine”. The first definition describes how a feature in VSM is structured; the second describes how information flows.
In VSM, the View renders the State. Each state may provide a Model. Each model contains the data and actions available in a given state. Each action in a model returns one or more new states. State changes update the view. VSM heavly leverages Swift 6 and structured concurrency so model actions express multi-step flows with:
StateSequenceand@StateSequenceBuilderAsyncStreamor otherAsyncSequencetypesawaitan asychronous function to return a state.
- The VSM documentation contains a complete framework reference, guides, and other learning resources.
- Migrating from VSM 1.x (LegacyVSM) to VSM 2.0 covers upgrading the dependency, the
LegacyVSMbridge,import/@ViewStatenaming, moving from publishers toStateSequence/async, and UIKit notes (includingRenderedViewStateon older iOS versions). - For LegacyVSM-only DocC content, open the documentation catalog under
Sources/LegacyVSM/Documentation.doccin Xcode or browse that folder in the repo (hosted Pages focus on the modern module). - Open the Shopping (Swift 6) demo (VSM 2.0) or LegacyShopping demo (VSM 1.x / LegacyVSM) to compare the same UI with the old and new styles.
This package ships two libraries:
| Product | Swift | Role |
|---|---|---|
VSM |
Swift 6 | Modern VSM: @ViewState, AsyncStateContainer, StateSequence, async observation APIs |
LegacyVSM |
Swift 5 | Compatibility: @LegacyViewState, Combine, publisher-based $state.observe |
If you still rely on Combine and the VSM 1.x pattern, use the LegacyVSM product (import LegacyVSM, @LegacyViewState) until you migrate. See Package layout and Migrating from legacy VSM. You can link both to the same app during migration: new or migrated screens use VSM; untouched screens stay on LegacyVSM. See the migration guide below for the recommended order of steps (bump the package → add LegacyVSM → rename imports/wrappers → migrate feature by feature).
The following excerpts sketch a small feature that loads a blog entry from a repository. Actions return StateSequence (and use async work inside) instead of Combine publishers.
The state is usually an enum (or other type) representing the phases the view can be in.
enum BlogEntryViewState {
case initialized(loaderModel: LoaderModel)
case loading(errorModel: ErrorModel)
case loaded(loadedModel: LoadedModel)
}Models implement the business logic. Multi-step transitions use @StateSequenceBuilder and StateSequence; individual steps can be async.
struct BlogEntry: Decodable {
let id: Int
let title: String
let body: String
}
struct LoadedModel {
let title: String
let body: String
}
struct ErrorModel {
let repository: BlogEntryProviding
let entryId: Int
let message: String
func retry() -> StateSequence<BlogEntryViewState> {
LoaderModel(repository: repository, entryId: entryId).loadEntry()
}
}
struct LoaderModel {
let repository: BlogEntryProviding
let entryId: Int
@StateSequenceBuilder
func loadEntry() -> StateSequence<BlogEntryViewState> {
BlogEntryViewState.loading(errorModel: nil)
Next { await self.fetchEntry() }
}
@concurrent
private func fetchEntry() async -> BlogEntryViewState {
do {
let blogEntry = try await repository.loadEntry(entryId: entryId)
let loadedModel = LoadedModel(title: blogEntry.title, body: blogEntry.body)
return .loaded(loadedModel: loadedModel)
} catch {
let errorModel = ErrorModel(
repository: repository,
entryId: entryId,
message: error.localizedDescription
)
return .loading(errorModel: errorModel)
}
}
}The view reads the current state from @ViewState and drives transitions with $state.observe(...), passing async sequences (or other supported async types) returned by the model—not Combine publishers.
struct BlogEntryView: View {
@ViewState var state: BlogEntryViewState
var body: some View {
switch state {
case .initialized(loaderModel: let loaderModel):
ProgressView()
.onAppear {
$state.observe(loaderModel.loadEntry())
}
case .loading(errorModel: let errorModel):
ZStack {
ProgressView()
if let errorModel = errorModel {
VStack {
Text(errorModel.message)
Button("Retry") {
$state.observe(errorModel.retry())
}
}
}
}
case .loaded(loadedModel: let loadedModel):
Text(loadedModel.title)
Text(loadedModel.body)
}
}
}This example uses SwiftUI; the framework also supports UIKit via @ViewState (with the platform requirements noted in the docs) or RenderedViewState where appropriate.
For step-by-step tutorials and API details, see the VSM documentation.
If your app was built on a single import VSM module with Combine and @ViewState observing publishers, VSM 2.0 changes that split: the async framework is still VSM; the Combine path lives in LegacyVSM as @LegacyViewState.
Follow Migrating from VSM 1.x (LegacyVSM) to VSM 2.0 for:
- Bumping the package and adding the
LegacyVSMproduct where needed - Replacing
import VSM→import LegacyVSMand@ViewState→@LegacyViewStatein files you have not migrated yet - Moving feature-by-feature to
import VSM,StateSequence, and asyncobserveoverloads
VSM for Apple platforms is owned and maintained by Wayfair.
See CONTRIBUTING.md.
See SECURITY.md.
VSM for Apple platforms is released under the MIT license. See LICENSE for details.
