How I Model SwiftUI Views

The approach I take to defining ViewModels for SwiftUI was heavily inspired by Paul Hudson’s post Introducing MVVM into your SwiftUI project. In it, he advocates for defining class ViewModel inside an extension to the relevant view. By naming each view’s MVVM class ViewModel, it can always be referenced directly by that name instead of remembering to use ContentViewViewModel, UserListViewModel, etc. I take it a step further and simply name the class Model:

import SwiftUICore

extension ContentView {
  @MainActor @Observable class Model {
    // properties and functions
  }
}

And each view has an instance of its ViewModel, and hopefully nothing else.

struct ContentView: View {
  @State private var model: Model = .init()
}

In practice, useful property wrappers like @Binding or @Environment will get hoisted into the view. I’ve also had absolutely zero luck incorporating this fully with SwiftData. In theory you can build your own Query with Predicate<PersistentModel> and SortDescriptor<PersistentModel>, but in practice I’m always getting bitten by oblique EXC_BAD_ACCESS and EXC_BREAKPOINT failure states caused by various race conditions, so @Query in the view is my current approach. I’ll still let the model do things like filter results for searchable(text:) though, by calling the model’s filter(_:) function with the View’s @Query var passing the result to a ForEach(_:, content:) or similar.

With this approach, my default is that any function or variable that the view needs should be put into the model. When a subview needs data from the parent, it’ll either be passed in as the value owned by the model: PersonView(person: model.person) or in cases where a mixture of data and behavior are required, I’ll pass in the entire model: SettingsView(model: model). Each of these subviews are expected to have their own Model class, and so I end up with init methods that take whatever arguments the view’s model needs to have passed to them and builds their own models:

init(person: Person) {
  self.model = Model(person: person)
}

The model extension lives in its own file, which is useful for testing as well as editing sanity. I find that the most typical case involves importing SwiftUICore which re-exports Foundation and provides useful things like Binding and Color for you. This is often also where I’ll import whatever framework the view is concerned with, such as WeatherKit or Contacts.

Because the view file isn’t included in the test target, I usually throw an empty struct into the tests file: struct ContentView {}.

Overall, this has been a really positive development for my Swift development. It makes all kinds of behaviors testable. It encourages me to push all but the simplest logic1 into the models. It doesn’t enable private helper methods, but it definitely makes them more obvious at least to my programmer brain that I should reach for that tool in a class that looks like this. It’s really helpful for me to have a separation of layout and presentation of the visual portion of an app from calculation and retrieval of the app’s data.

  1. I’m perfectly happy to leave something like .background(model.active ? .blue : .orange) in the view, but if the code grows from that level of complexity it’ll get pushed down to the Model.