Stop using MVVM for SwiftUI

Don’t over-engineering! No suggested architecture for SwiftUI, just MVC without the C.

On SwiftUI you get extra (or wrong) work and complexity for no benefits. Don’t fight the system.

Thanks very much for these posts. I'll collect all my questions here since the comments are limited:

  1. In your first example, would it be better to not use Active Record, or if we do, to make sure mutations happen through the StateObject and not by calling functions directly on the active record?
  2. In the second example, When you say call objectWillChange.send(), is this replacing @Published? I.e: We call objectWillChange.send(), then SwiftUI will redraw the view, and automatically get the new contents when it re-reads Folder.folders?
  3. In the second part of that same post, we're depending on the FileWatcher to invalidate the view right? I.e. FileWatcher should be sending an objectWillChange.send(), so we get all the correct values inside the view body when all of the properties are re-queried?
  4. If we're not planning on re-using components but are developing everything in the same app via SwiftUI, does Active Record have any advantages, or is it better to handle everything through StateObjects? Does Active Record make more sense when developing a "general" component outside of the SwiftUI / combine framework then? I.e. like how StoreKit2 was designed?
  5. Finally, when implementing Active Record, since we're using static vars, does it make sense to call into singletons the way I did it, via the shared static vars? Or otherwise how is this generally done? How does the Active Record actually access the objects that it needs to via those static vars? If you want to swap implementations, for testing purposes or maybe because you actually need different implementations at runtime, how is this usually handled?

SwiftUI is really interesting and I'm finding it much easier to develop out new features. Learning about these patterns is super helpful, so thanks for taking the time to engage!

1 - Active Record (like Data Mapper or Repository) is a pattern, very popular, for data access, we use where needed, I use some example to show that model is not only property’s structs. For a library you can use Product, Order as active record but for an app you should include a ProductStore (state), OrderStore (state)… also you can implement all Product, Order tasks on Store and not use Product, Order as active record at all. Remember: typically a framework, like StoreKit 2, need to be flexible, more stateless. A framework is used by an app. For an app like SwiftUI you need one or more state objects, stateful.

2 - Yes, objectWillChange.send() do the @Published job, also @Published is a convenience for objectWillChange.send() and use it. See 2019 post about this, initially (SwiftUI betas)we don’t have @Published. Forget in last posts, you should call objectWillChange.send() before you change the property, not after, be careful with asyncs stuffs.

@Published var selectedFolder: Folder? = nil

=

var selectedFolder: Folder? = nil {
    willSet {
        objectWillChange.send()
    }
}

3 - Yes!

4 - As I said in 1 point, you can handle everything on ObservableObject and keep only property structs, and just add some needed tasks to structs.

struct Product {
    // Properties (data)

    // Tasks
    func purchase() async throws { ... }

    // Factory Method
    static products: [Self] {
        get async throws {
           return try await WebService.shared.request(…)
        }
    }
}

class ProductStore: ObservableObject {
    @Published var products: [Product] = []
    
    func load() async { 
        do {
            products = try await Product.products
        } catch {
            // handle error
        }
    }
}

or

struct Product {
    // Properties (data)

    // Tasks
    func purchase() async throws { ... }
}

class ProductStore: ObservableObject {
    @Published var products: [Product] = []
    
    func load() async { 
        do {
            products = try await WebService.shared.request(…)
        } catch {
            // handle error
        }
    }
}

or

struct Product {
    // Properties (data)
}

class ProductStore: ObservableObject {
    @Published var products: [Product] = []
    
    func purchase(_ product: Product) async throws { ... }

    func load() async { 
        do {
            products = try await WebService.shared.request(…)
        } catch {
            // handle error
        }
    }
}

5 - In active record you use static func / vars for “Factory Method”, not single instance. For this pattern you only use shared (single instance) for your service / provider objects (e.g. WebService.shared.request(…)). And in general for SwiftUI you should avoid singleinstance for non service / provider objects, use @EnvironmentObject.

ObservableObject is a working object where we aggregate related data and tasks.

We see ObservableObject as:

  • Model object
  • Business object
  • State, life-cycle, management object
  • User case or related use cases object

ObservableObject (working, in-memory data) from:

  • Computed
  • Disk (local)
  • Database (local)
  • Network (remote)
  • System Services

Example: My last app (multiplatform) have in-app purchases (use StoreKit 2), I have a SubscriptionStore (ObservableObject) for:

  • Load my web service features (multiplatform)
  • Load StoreKit products from features (call Product.products(featureIDs))
  • refreshPurchasedProducts (handle Transaction.currentEntitlements)
  • check feature availability based on StoreKit purchase / or my web service information (multiplatform)

I can add purchase task to SubscriptionStore but use product.purchase() from StoreKit 2 in View. As I use this object in different views I use @EnvironmentObject to have one instance and access from any view in hierarchy.

The app use 2 data access models based on Active Record, the my data (web service) model (part active record, part handle some things in the stores) and the StoreKit 2 model.

Remember: Active Record is about data access, not state. SwiftUI views need a state, local (@State) and external (@StateObject).

Imagine the CoreLocation 2 using await / async, no delegates. Now we use CLLocation (property only struct) and CLLocationManager (object). In future we could use Location as Active Record:

  • try await Location.current (gives current user location)
  • for await location in Location.updates (gives every locations changes, async sequence)

Also, how the long wait SwiftData (Core Data next generation) be like:

We have Active Record for data access in PHP Laravel database and Swift server-side Vapor (Fluent database).

Why doesn't Apple just come out and say if they designed SwiftUI to use MVVM or MV?

Easy, the MVVM comes from old technology. Also Microsoft, who sell MVVM from 2005 is not using it on new UI framework (declarative). SwiftUI is new tech, modern, declarative. They eliminate the middle layer. Is part of evolution, simplicity. We only need a View layer and a Model layer. Inside Model layer we can do what we want (also some VMs I see on blogs and tutorials are state / store, part of the model). We can’t use MVVM for SwiftUI without problems and limitations.

Talking and wanting MVVM or MVC today is the same wanting C++ or Pascal. No one want go back! There’s a modern and more easy techs.

The evolution:

  • C -> C++ -> Java / C# -> Swift
  • MVC -> MVVM -> MV

SwiftUI automatically performs most of the work traditionally done by view controllers. Fact: SwiftUI View is a ViewModel.

To remember! The model layer are not (and never was) only property structs. The model layer is our data, services / networking, state, business objects, processors, …

Many devs don’t understand the MVC / MVVM. Fact: VM (reactive) = C (imperative)

It's obvious - with SwiftUI we should drop the C from MVC, but still we should keep the business logic somewhere.

Let's try to reason where is the best place? (We are developers working on a moderate iOS project - not a small one, but not something huge.)

  • in the view? - No. (Too many reasons to avoid it - complex views, unreadable views, hard to unit test, etc.)

  • in the model - Probably.

  • in the controller - No. There is no controller in SwiftUI.

  • Otherwise we need some place which we can call whatever we want. (Some people prefer to use "ViewModel". Please, don't mess this with MVVM design pattern.).

Another generic problem that we should solve - caching (because we fetch some data from a server and we store it on the device, while the app is running, or even persist it between different sessions.) Also this data may be updated from time to time based on other users (imagine a chat functionality in an app).

Who should be responsible for storing that data? It's not the view. It's not the controller. Guess, who? - The model.

Well, if we continue the same way, the model is not anymore a typical model (closer to POJO) - it's growing and getting wiser and powerful (and massive :D). Which leads to some sort of separation if we want to make our codebase maintainable.

In summary - if the project you are working is growing then you have to introduce something that will make it easier to grow. It's up to you to pick the right tools.

MVVM or something similar might be an overkill in the beginning of any project. But at some point partially using any architectural Design Pattern (or just the good bits) will solve a lot of problems.

Another generic problem that we should solve - caching (because we fetch some data from a server and we store it on the device, while the app is running, or even persist it between different sessions.) Also this data may be updated from time to time based on other users (imagine a chat functionality in an app). Who should be responsible for storing that data? It's not the view. It's not the controller. Guess, who? - The model.

Yes 🙌

Finished an app with caching (local data sync from remote), our model have few “stores” as source of truth to do that job, and again EnvironmentObject is our best friend.

Another Example

We can have a single store for everything (not recommended for big, multi section / tab apps) or multi stores, separated by use case / data type / section / tab. Again, stores (ObservableObjects), we can call other names, are part of the model (yes we can separate from our data model). Also we can do everything in stores and not use active record pattern at all but my last experience tell me that is good to separate our data model (using a data access pattern) from state (we can call store object, business object, use case object, state object, external source of truth object, …).

This is the SwiftUI (declarative UI) approach. With MVVM pattern:

  • Requires middle layer and more code
  • Need one ViewModel for each View
  • Problems with shared state (e.g. EnvironmentObject)
  • Problems with local state (e.g. FocusState, GestureState, …)
  • Overall platform conflicts
  • Overall external data management limitations
  • Duplicating… SwiftUI View is a “ViewModel”
  • Massive ViewModels (yes can happen)

Who needs a ViewModel today?

Little, simple and clean code. Everything works great on iOS, iPadOS, macOS, tvOS, Unit / Integration Tests server. Very productive team with well defined responsabilities. Note: Just an example based on real SwiftUI app.

If the user wants to favorite channels and VOD movies, how would you handle that?

1- A list of [Channel] and [Movie] in your AccountStore (user state/data) ? 2- Or if we want to keep only the ID (eg. [Channel.ID] + [Movie.ID]), we need to map these ids to their object after app launch. Is it the Account that access the data? or do you have a nested dependency to access the data only via ChannelStore and VODStore (requiring these stores load the data before to not have empty collection)

ChannelStore.load
VideoOnDemand.load
Account.load + map Channel/VOD.ID.

3- Can different store access the same underlying data via your Active record object? or if a View needs data from 2 different store to map them to a new object type.

4- Also curious about an active record example. what is inside these "..." in the static methods or functions. Movie.all { ??? } do you always call Webservice.shared.allMovies? are you using a cache? return Cacheservice.shared.allMovie ?? Webservice.shared.allmovies

thank you!

Simple question: what if the data format in your model does not correspond with how you want to show it on screen? E.g. it's not simply a String in your model that you show in a TextField component, but you need to split it over 3 different TextField components and merge it when you put it back in the model?

Unless I am mistaken I haven't seen one word about converting data.

Model vs Form (View)

What if the data format in your model does not correspond with how you want to show it on screen? Depends on your needs. From my experience I find using local state (1.2) for form data, then convert to your model the best approach. Form should follow the Model. Remember View = f(Model).

struct RegistrationInfo: Codable {
    var name: String = ""
    var email: String = ""
    var phone: String = ""
    var age: Int = 19
    
    func submit() async throws { ... }
}
​
// Convenience if needed
extension RegistrationInfo {
    var firstName: String { String(name.split(separator: " ").first ?? "") }
    var lastName: String { String(name.split(separator: " ").last ?? "") }
}
​
// 1.1 - Using view local state (direct), need a tricky solution
struct RegistrationForm: View {
    @State private var info = RegistrationInfo()
    
    @State private var isSubmitting: Bool = false
    @State private var submitError: Error? = nil
    
    var body: some View {
        Form {
            Section("Name") {
                TextField("First name", text: Binding(get: { info.firstName }, set: { value, _ in ???? }))
                TextField("Last name", text: Binding(get: { info.lastName }, set: { value, _ in ???? }))
            }
            
            // ...
            
            Button("Submit") {
                Task {
                    isSubmitting = true
                    do {
                        try await info.submit()
                    } catch {
                        submitError = error
                    }
                    isSubmitting = false
                }
            }
        }
    }
}
​
// 1.2 - Using view local state (indirect)
struct RegistrationForm: View {
    @State private var firstName: String = ""
    @State private var lastName: String = ""
    
    @State private var email: String = ""
    @State private var phone: String = ""
    @State private var age: Int = 18
    
    @State private var isSubmitting: Bool = false
    @State private var submitError: Error? = nil
    
    var body: some View {
        Form {
            Section("Name") {
                TextField("First name", text: $firstName)
                TextField("Last name", text: $lastName)
            }
            
            // ...
            
            Button("Submit") {
                Task {
                    isSubmitting = true
                    do {
                        let info = RegistrationInfo(name: "\(firstName) \(lastName)",
                                                    email: email,
                                                    phone: phone,
                                                    age: age)
                        try await data.submit()
                    } catch {
                        submitError = error
                    }
                    isSubmitting = false
                }
            }
        }
    }
}
​
// 2 - Using an external state, object part of your model
class Registration: ObservableObject {
    @Published var firstName: String = ""
    @Published var lastName: String = ""
    
    @Published var email: String = ""
    @Published var phone: String = ""
    @Published var age: Int = 18
    
    @Published var isSubmitting: Bool = false
    var submitError: Error? = nil
    
    func finish() async {
        isSubmitting = true
        do {
            let data = RegistrationInfo(name: "\(firstName) \(lastName)",
                                        email: email,
                                        phone: phone,
                                        age: age)
            try await data.submit()
        } catch {
            submitError = error
        }
        isSubmitting = false
    }
}
​
struct RegistrationForm: View {
    @State private var registration = Registration()
    
    var body: some View {
        Form {
            Section("Name") {
                TextField("First name", text: $registration.firstName)
                TextField("Last name", text: $registration.lastName)
            }
            
            // ...
            
            Button("Submit") {
                Task {
                    await registration.finish()
                }
            }
        }
    }
}

Channels

There’s many ways for it, depending on our needs. Just some ideas.

  • Typically a small & limited data set
  • For cache use HTTP / URLSession caching system (defined by the server)
  • For offline use the stores (no needed in many cases)

Model layer - Example 1

struct Channel: Identifiable, Hashable, Codable {
    let id: String
    // Data
    var isFavorite: Bool
    
    // Factory Methods
    static var channels: [Self] {
        get async throws {
            try await MyTVWS.shared.request(resource: "channels",
                                            verb: .get)
        }
    }
}
​
class ChannelStore: ObservableObject {
    @Published var channels: [Channel] = []
    
    var favorites: [Channel] { channels.filter { $0.isFavorite } }
    
    @Published var isLoading: Bool = false
    var loadError: Error? = nil
    
    func load() async {
        isLoading = true
        do {
            channels = try await Channel.channels
        } catch {
            loadError = error
        }
        isLoading = false
    }
}

Model layer - Example 2

Check if channel is favorite on the “favorites” store.

struct Channel: Identifiable, Hashable, Codable {
    let id: String
    // Data
    
    func addToFavorites() async throws { ... }
    func removeFromFavorites() async throws { ... }
    
    // Factory Methods
    static var channels: [Self] {
        get async throws {
            try await MyTVWS.shared.request(resource: "channels",
                                            verb: .get)
        }
    }
    
    static var favoriteChannels: [Self] {
        get async throws {
            try await MyTVWS.shared.request(resource: "favoriteChannels",
                                            verb: .get)
        }
    }
}
​
// 2.1
class ChannelStore: ObservableObject {
    @Published var channels: [Channel] = []
    @Published var favoriteChannels: [Channel] = []
    
    @Published var isLoading: Bool = false
    var loadError: Error? = nil
    
    func load() async {
        isLoading = true
        do {
            channels = try await Channel.channels
            favoriteChannels = try await Channel.favoriteChannels
        } catch {
            loadError = error
        }
        isLoading = false
    }
}
​
// 2.2
class ChannelStore: ObservableObject {
    @Published var channels: [Channel] = []
    
    @Published var isLoading: Bool = false
    var loadError: Error? = nil
    
    enum Source {
        case all
        case favorites
    }
    
    private let source: Source
    
    init(_ source: Source) {
        self.source = source
    }
    
    func load() async {
        isLoading = true
        do {
            switch source {
            case .all:
                channels = try await Channel.channels
            case.favorites:
                channels = try await Channel.favoriteChannels
            }
        } catch {
            loadError = error
        }
        isLoading = false
    }
}
​
// 2.3
class ChannelStore: ObservableObject {
    @Published var channels: [Channel] = []
    
    @Published var isLoading: Bool = false
    var loadError: Error? = nil
    
    open func load() async { }
}
​
class AllChannelStore: ChannelStore {
    func load() async {
        isLoading = true
        do {
            channels = try await Channel.channels
        } catch {
            loadError = error
        }
        isLoading = false
    }
}
​
class FavoriteChannelStore: ChannelStore {
    func load() async {
        isLoading = true
        do {
            channels = try await Channel.favoriteChannels
        } catch {
            loadError = error
        }
        isLoading = false
    }
}
​
// 2.4
class ChannelStore: ObservableObject {
    @Published var channels: [Channel] = []
    
    @Published var isLoading: Bool = false
    var loadError: Error? = nil
    
    open func loadChannels() async throws { }
    
    func load() async {
        isLoading = true
        do {
            try await loadChannels()
        } catch {
            loadError = error
        }
        isLoading = false
    }
}
​
class AllChannelStore: ChannelStore {
    func loadChannels() async throws {
        channels = try await Channel.channels
    }
}
​
class FavoriteChannelStore: ChannelStore {
    func loadChannels() async throws {
        channels = try await Channel.favoriteChannels
    }
}

View layer - Based on Example 1

struct ChannelList: View {
    @EnvironmentObject private var channelStore: ChannelStore
    
    enum Mode {
        case all
        case favorites
    }
    
    @State private var mode: Mode = .all
    
    var body: some View {
        VStack {
            Picker("", selection: $mode) { ... }
                .pickerStyle(.segmented)
            
            ScrollView {
                LazyVStack {
                    switch mode {
                    case .all:
                        ForEach(channelStore.channels) { channel in
                            ChannelCard(channel: channel)
                        }
                    case .favorites:
                        ForEach(channelStore.favoriteChannels) { channel in
                            ChannelCard(channel: channel)
                        }
                    }
                }
            }
        }
    }
}
​
struct ChannelCard: View {
    var channel: Channel
    
    var body: some View { ... }
}
​
struct ProgramList: View {
    @EnvironmentObject private var channelStore: ChannelStore
    
    var body: some View { ... }
}
​
struct LivePlayerView: View {
    @EnvironmentObject private var channelStore: ChannelStore
    
    var body: some View { ... }
}

Movies

  • Typically a big & unlimited data set
  • For cache use HTTP / URLSession caching system (defined by the server)
  • For offline use the stores (no needed in many cases)

Model layer

struct Movie: Identifiable, Hashable, Codable {
    let id: String
    // Data
    
    enum Section: String, Codable {
        case all
        case featured
        case favorites
        case currentlyViewing
        // ...
    }
    
    enum Genre: String, Identifiable, Codable, CaseIterable {
        case action
        case comedy
        case drama
        case terror
        case animation
        case science
        case sports
        case western
        // ...
        
        var id: Self { self }
    }
    
    // Factory Method
    static func movies(pageNumber: Int = 1,
                       pageSize: Int = 30,
                       search: String? = nil,
                       section: Movie.Section = .all,
                       genres: [Movie.Genre] = [],
                       sort: String? = nil) async throws -> [Self] {
        try await MyTVWS.shared.request(resource: "movies",
                                        verb: .get,
                                        queryString: ...)
    }
    
    // --- or ---
    // (recommended)
    
    struct FetchOptions {
        var pageNumber: Int = 1
        var pageSize: Int = 30
        var search: String? = nil
        var section: Section = .all
        var genres: [Genre] = []
        var sort: String? = nil
    }
    
    static func movies(_ options: FetchOptions) async throws -> [Self] {
        try await MyTVWS.shared.request(resource: "movies",
                                        verb: .get,
                                        queryString: ...)
    }
}
​
class MovieStore: ObservableObject {
    @Published var movies: [Movie] = []
    
    @Published var isLoading: Bool = false
    var loadError: Error? = nil
    
    var options: Movie.FetchOptions
    
    init(_ options: Movie.FetchOptions) {
        self.options = options
    }
    
    // Add convenience initializings if wanted
    init(_ section: Movie.Section,
         genre: Movie.Genre? = nil
         limit: Int = 15) {
        self.options = ...
    }
    
    func load() async {
        isLoading = true
        do {
            movies = try await Movie.movies(options)
        } catch {
            loadError = error
        }
        isLoading = false
    }
    
    func loadNextPage() async { ... } // Infinite scrolling
}

View layer

struct MovieList: View {
    enum Mode {
        case home
        case genres
    }
    
    @State private var mode: Mode = .home
    
    var body: some View {
        VStack {
            Picker("", selection: $mode) { ... }
                .pickerStyle(.segmented)
            
            ScrollView {
                LazyVStack {
                    switch mode {
                    case .home:
                        MovieRow(store: MovieStore(.featured))
                        MovieRow(store: MovieStore(.currentlyViewing))
                        MovieRow(store: MovieStore(.favorites))
                    case .genres:
                        ForEach(Movie.Genre.allCases) { genre in
                            MovieRow(store: MovieStore(.all, genre: genre))
                        }
                    }
                }
            }
        }
    }
}
​
// Each row can be limited to n items and have a "View all" button to push MovieGrid with all items
struct MovieRow: View {
    @StateObject var store: MovieStore
    
    var body: some View { ... }
}
​
struct MovieGrid: View {
    @StateObject var store: MovieStore
    
    var body: some View { ... }
}
​
struct MovieCard: View {
    var movie: Movie
    
    var body: some View { ... }
}

About the service object

Active Record pattern

StoreKit 2 approach

// Handles requests, environments, tokens, …
// General access to an specific web service using an URLSession instance
class MyTVWS {
    static let shared = MyTVWS()

    func request(…) async throws -> T { … }
}

struct Channel: Codable {
    // Data (properties)

    // Factory Methods
    static var channels: [Self] {
        get async throws {
            try await MyTVWS.shared.request(resource: "channels",
                                            verb: .get)
        }
    }
}

struct Movie: Codable {
    // Data (properties)
    
    // Factory Methods
    static func movies(pageNumber: Int = 1,
                       pageSize: Int = 30) async throws -> [Self] {
        try await MyTVWS.shared.request(resource: "movies",
                                        verb: .get,
                                        queryString: ...)
    }
}

Advantages:

  • Better code and object organization
  • Direct object task access
  • Works great for modular (multi model) architectures
  • Easy for team member responsibilities
  • Perfect for scalability and maintenance
  • Clean and easy to use
  • True OOP and Separation of Concerns approach

Disadvantages:

  • SOLID and anti-OOP principles / patterns devs don’t like it

Massive service object strategy, POJOs

WeatherKit approach, from a team (Dark Sky) that Apple acquired

// Handles requests, environments, tokens, …
// Specific access to an specific web service using an URLSession instance
class MyTVWS {
    static let shared = MyTVWS()

    func getChannels() async throws -> [Channel] {
            try await MyTVWS.shared.request(resource: "channels",
                                            verb: .get)
    }

    func getMovies(pageNumber: Int = 1,
                   pageSize: Int = 30) async throws -> [Movie] {
        try await MyTVWS.shared.request(resource: "movies",
                                        verb: .get,
                                        queryString: ...)
    }
}

struct Channel: Codable {
    // Data (properties)
}

struct Movie: Codable {
    // Data (properties)
}

Advantages:

  • Simple data objects

Disadvantages:

  • Massive single service object (many responsibilities)
  • Code fragmentation (e.g. Channel related code and functionality present in different files / objects)
  • Scalability and maintenance problems (e.g. many devs working / changing on single object with many responsibilities)

Checkout process example

SwiftUI approach

// Model layer
class Checkout: ObservableObject { ... }

// View layer
struct CheckoutView: View {
    @StateObject private var checkout = Checkout()
    
    var body: some View {
        NavigationStack {
            CheckoutProductInfoForm()
            // -> CheckoutOffersView()
            // -> CheckoutBuyerForm()
            // -> CheckoutDeliveryInfoForm()
            // -> CheckoutSummaryView()
        }
        .environmentObject(checkout)
    }
}

Advantages:

  • Clean, simple and data-driven development
  • Core for declarative UI platforms
  • Checkout model object is independent from a specific View and platform
  • Works great for multiplatform (inside Apple ecosystem)

Disadvantages:

  • Other platform devs don’t (yet) understand it

MVVM approach

// Need a model or helper object to share / joint data between the VMs

// ViewModel layer
class CheckoutProductInfoViewModel: ObservableObject { ... }
class CheckoutOffersViewModel: ObservableObject { ... }
class CheckoutBuyerViewModel: ObservableObject { ... }
class CheckoutDeliveryInfoViewModel: ObservableObject { ... }
class CheckoutSummaryViewModel: ObservableObject { ... }

// View layer
struct CheckoutView: View {
    var body: some View {
        NavigationStack {
            CheckoutProductInfoView() // <- CheckoutProductInfoViewModel
            // -> CheckoutOffersView() <- CheckoutOffersViewModel
            // -> CheckoutBuyerView() <- CheckoutBuyerViewModel
            // -> CheckoutDeliveryInfoView() <- CheckoutDeliveryInfoViewModel
            // -> CheckoutSummaryView() <- CheckoutSummaryViewModel
        }
    }
}

Advantages:

  • Sometimes we feel that using VMs can help to avoid massive views (but not really necessary -> SwiftUI component nature)

Disadvantages:

  • A middle layer, unnecessary, more objects / files, more code, more complexity
  • Not easy to handle some use cases, becomes problematic in some situations
  • Not easy to share a state in view hierarchy
  • ViewModel-View dependence becomes bad for multiplatform (iOS UI != iPad UI != TV UI …)
  • Old approach, not suitable for declarative platforms
  • Can fight the SwiftUI platform

Thank you for your examples.

case .home:
    MovieRow(store: MovieStore(.featured))
    MovieRow(store: MovieStore(.currentlyViewing))

Here, if a Movie is editable (for the example), Movie.A is edited in the first MovieRow/Grid -> Editor. And, this same movie is in the second row (currentlyViewing), it won't be refreshed. A Store with a computed var to filter movies can fix this problem here (or even a binding of a movie subset on the global list). it's more an app problem and how to manage the underlying data (local chache, persistence etc)^^'

In your

struct Channel: Identifiable, Hashable, Codable {

func addToFavorites() async throws { ... }

}

I guess you have the same type of code with the service object?


struct Channel: Identifiable, Hashable, Codable {

func addToFavorites() async throws {
	self.isFavorite = true
	// or
	try await MyTVWS.shared.favorite(movieId: self.id)
}

}

And if a Store needs Data from 2 types to do his work, you'll do something like this ?

class SearchStore {
	@Published var movies: [Movie] = []
	@Publisehd var channels: [Channel] = []

	func search() async {
        isSearching = true
        do {
            async let movieResults = Movie.search(option: MovieSearchOptions)
            async let channelResults = Channel.search(option: ChannelSearchOptions)
        	let results = try await [movieResults, channelResults]
        	// or could be different to display results as soon as possible for a type
        } catch {
            searchingError = error
        }
        isSearching = false
    }
}

ActiveRecord looks nice but how would change a service implementation (not necessary the environment at init time) as it is tightly coupled in the model. Did you have the problem and a recommended approach? Or in many cases the question to change wasn't even here (to not over engineering before it is needed)?

MyTVWebService.shared.favorite(movieId: self.id) with

MyTVLocalService.shared.favorite(movieId: self.id) to

class MovieStore: ObservableObject {
	// online 
	init(with service: MyTVWS = .remote) {}
	// -- or --
	// local
	init(with service: MyTVWS = .local) {}

}

Anyway, I'll keep reading your posts, some interesting thought and approach :) I was - and still - never a fan of VIPER, TCA, MVVM for everything even if not needed, and ActiveRecord is another approach that I like (+ reading Apple MusicKit / StoreKit2 confirms it - Too bad we can not see what's behind their static method.)

It takes time to think differently than traditionnal OOP. Same than you, I worked on multi layer projects and your "Massive service object strategy" is an exact copy of what we had !

  • other horrible things including useless unit/integration tests.

6-years ago I started a Unity project with ECS structure instead of OOP and it was really fast to do anything. You want to change/ add a SDK => add a new system An analytics event ? => create a new data and any systems can listen and react to this event.

Think SwiftUI with

  • all the app data in a global context
  • ObservableObjects + View are just systems / puzzle pieces on top of that and react only when their own necessary data updates (or is added/removed)

I would love that and that's why I like SwiftUI since day one as it is close to this .

As I said before we can do all the work in the stores (SOT) and keeping Channel and Movie plain (only properties). Personally I use Active Record to separating the data access from Source of Truth. Also I have cases that I use the some data object factory method in multiple store. And some different but related projects that use the some web services (data access) but not the Source of Truths (stores). Active Record works great for library / frameworks and integration tests. Also in a team we can split the responsibilities with team members… Data Access, Source of Truth, View, Shared, …

Channels tasks

struct Channel {
    // Data
    var isFavorite: Bool {
        didSet {
            Task {
                try? await MyTVWS.shared.request(resource: "channels/\(id)",
                                                 verb: .patch,
                                                 payload: ...)
            }
        }
    }
}

struct Channel {
    // Data
    
    // Tasks
    func addToFavorites() async throws { 
        try await MyTVWS.shared.request(resource: "channels/\(id)",
                                        verb: .patch,
                                        payload: ...)
    }
    
    func removeFromFavorites() async throws { 
        try await MyTVWS.shared.request(resource: "channels/\(id)",
                                        verb: .patch,
                                        payload: ...)
    }
}

Movies management

Example 1 - Use the NotificationCenter to broadcast a Movie change, very common in IOS platform for model change notifications, include the changed movie object in "sender" or "info", add observer on the MovieStore and update / reload / invalidate the store.

extension Notification.Name {
    static let movieDidChange = Notification.Name("movieDidChangeNotification")
}

Example 2 - MovieStore as a Single Source of Truth. Be careful with large data sets (memory). In this example you can see why I use Active Record, I can use Movie.movies factory method in many situations.

class MovieStore: ObservableObject {
    @Published var featured: [Movie] = []
    @Published var currentlyViewing: [Movie] = []
    @Published var favorites: [Movie] = []
    @Published var byGenres: [Movie.Genre: Movie] = []
    
    @Published var allMovies: [Movie] = [] // current grid
    
    // load, phase, ...
}

struct MovieList: View {
    @StateObject private var store = MovieStore()
    
    // mode, ...
    
    var body: some View {
        VStack {
            Picker("", selection: $mode) { ... }
                .pickerStyle(.segmented)
            
            ScrollView {
                LazyVStack {
                    switch mode {
                    case .home:
                        MovieRow(..., movies: store.featured)
                        MovieRow(..., movies: store.currentlyViewing)
                        MovieRow(..., movies: store.favorites)
                    case .genres:
                        ForEach(...) { movies in
                            MovieRow(..., movies: movies)
                        }
                    }
                }
            }
            .environmentObject(store)
        }
    }
}

Example 3 - MovieStore as movie sync manager for Core Data (in memory or in disk) / relational database. Requires more work and a local data model.

class MovieStore: ObservableObject { ... } // movies sync and management

struct MovieRow: View {
    @FetchRequest(...)
    private var movies: FetchedResults<Movie>

    // ...
}

There’s an unlimited solutions (and system frameworks) for our problems. We just need to think in our model and how designing the model (data-driven SwiftUI nature) that fits app use cases.

Note: For a single feature / data source apps (e.g. Mail, Notes, Reminders, …) we can use a global / single state ObservableObject. But in many apps we made we have many sections / features / data sources and we need more ObservableObjects / Stores (SOT). Also from my experience ObservableObject to ObservableObject communication / observation is not good and can become confuse, I avoid flow like this: View - ObservableObject - ObservableObject - Data Object

Be careful with large data sets (memory). In this example you can see why I use Active Record, I can use Movie.movies factory method in many situations.

Yes with static data, Active Record can be nice. With large dataset, we needs to be careful on the "Single Source of Truth" (memory, data sync, .)

Also from my experience ObservableObject to ObservableObject communication / observation is not good

Agree, I also avoid nested observables. Often it can be resolved with smaller views+logic / container view.

Apple use ECS for GameplayKit. It’s great for Games but don’t see it in Apps today (

Yes we used it for a game (not GameKit). And on a second project it was for everything (Gameplay + UI + Services). When you say View = f(State), it is the same thing with ECS. We have something close with the existing SwiftUI but maybe more later with an official framework or else. With the sheet modifier it is what we had a lot for UI: set a flag, the view controller listen and show the popup.

Global context, Gameplay context, UI context, services context, you can split as needed by the app.

  • Global -> static data, configuration etc
  • Gameplay -> player position, events, game state
  • UI -> UI state, rewards available, current tab, intent to show with priority
  • services -> initialize and handle the logic for services, analytics, socials, persistence, sync etc.

Of course, the ECS performance gain is overkill for a UI based app but the data logic and system execution (≠ OOP) is extremely easy to use, maintainable, and evolvable. Subject for another thread!

Thank you for your detailed posts / examples.

Just watched the session “13 - Lessons learnt rewriting SoundCloud in SwiftUI - Matias Villaverde & Rens Breur” from NSSpain. They rewrite the UIKit app to SwiftUI using “Clean Architecture” + “View Models”… and guess… they failed!

Again, learn and understand SwiftUI paradigm before start a project. Don’t fight the system, Keep It Simple!

Is it ok to pass environmentObjects to a stateObject using .onAppear if we want that state object to help us manage the data to be read/written in the environmentObject? Just so we don't clutter the View struct with lots and lots of code? For instance when creating code to edit a user profile, where a lot of validation is needed?

e.g.

struct EditPersonalInfoView: View {

  @StateObject var userDataValidation = UserDataValidation()   
  @EnvironmentObject var userInfo: UserInfo

  var body: some View {
    List {
      .... /* a bunch of fields here, validation code inside `userDataValidation` */
    }
    .onAppear {
      userDataValidation.initialize(userInfo: userInfo)
    }
  }
}

We can't pass the userInfo object to userDataValidation in the constructor, for obvious reasons.

The SwiftUI team actually advise against what you're suggesting. Here is a SwiftUI engineer from Apple's most recent QA session.

Software Engineering (design / modeling / specs) is one of the most important work of our jobs. I’m trying to find a simple diagram for SwiftUI to:

  • Identify Objects (structs / classes)
  • Identify Tasks (methods)
  • Identify Dependencies

A modified version of old (but gold) DFD (Data-flow diagram) fits very well on SwiftUI data-driven architecture. Also helps to understand how SwiftUI works. I’m calling it “Data-driven diagram”. The “data element” in diagram is independent of Data Access Patterns (Active Record, Repository, POJOs, …).

Shopping Example

Books App Example

Stop using MVVM for SwiftUI
 
 
Q