SwiftUI Architecture: Shared vs. Separate ViewModels for Your App

Deepak Tundwal
3 min readDec 26, 2023

--

Introduction

When developing iOS apps with SwiftUI, one common challenge is managing data across multiple views. This is particularly true in scenarios where we have a list of items and a detail view for each item — a common pattern in many apps. A crucial decision in such scenarios is whether to use a shared ViewModel for both views or to have separate ViewModels.

Let’s dive into these two architectural approaches, using the example of a product listing and editing application.

Approach 1: Separate ViewModels for Each View

ProductsList ViewModel

class ProductListViewModel: ObservableObject {
@Published var products: [Product] = []
    func fetchProducts() {
// Fetch product list logic
}
}
struct Product: Identifiable {
let id: Int
let name: String
// Add other product properties
}

ProductDetail ViewModel

class ProductDetailViewModel: ObservableObject {
@Published var productDetail: ProductDetail?
    func fetchProductDetail(id: Int) {
// Fetch product detail logic
}
}
struct ProductDetail {
// Product detail properties
}

Views

struct ProductListView: View {
@StateObject var viewModel = ProductListViewModel()
    var body: some View {
List(viewModel.products) { product in
NavigationLink(destination: ProductDetailView(productId: product.id)) {
Text(product.name)
}
}
.onAppear {
viewModel.fetchProducts()
}
}
}
struct ProductDetailView: View {
@StateObject var viewModel = ProductDetailViewModel()
let productId: Int
var body: some View {
// Display product details
Text(viewModel.productDetail?.name ?? "")
.onAppear {
viewModel.fetchProductDetail(id: productId)
}
}
}

Approach 2: Single ViewModel for Multiple Views

Shared ViewModel for Both Screens

class SharedProductsViewModel: ObservableObject {
@Published var products: [Product] = []
@Published var selectedProductDetail: ProductDetail?
    func fetchProducts() {
// Fetch product list logic
}
func fetchProductDetail(id: Int) {
// Fetch product detail logic
}
}

Views

struct ProductListView: View {
@StateObject var viewModel = SharedProductsViewModel()
    var body: some View {
List(viewModel.products) { product in
NavigationLink(destination: ProductDetailView(productId: product.id, viewModel: viewModel)) {
Text(product.name)
}
}
.onAppear {
viewModel.fetchProducts()
}
}
}
struct ProductDetailView: View {
let productId: Int
@ObservedObject var viewModel: SharedProductsViewModel
var body: some View {
// Display product details
Text(viewModel.selectedProductDetail?.name ?? "")
.onAppear {
viewModel.fetchProductDetail(id: productId)
}
}
}

Comparison and Analysis:

  • Approach 1 has distinct ViewModels for the product list and product details. This provides separation of concerns but can lead to duplicated code for shared functionality and challenges in maintaining state consistency between the views.
  • Approach 2 uses a single shared ViewModel. It provides a unified source of truth for both views, ensuring consistency and reducing redundancy. However, the ViewModel can become complex if it has to handle many different concerns.

Both approaches are valid, and the choice between them depends on the complexity of your app and the degree of interaction between the views. For simpler apps or when views have distinct concerns, separate ViewModels may be preferable. For more complex apps with closely related views, a shared ViewModel might be more efficient.

How about @EnvironmentObject?

Using @EnvironmentObject is another viable approach in scenarios where you need to share data across multiple views, and it can be particularly useful in the context of the MVVM design pattern in SwiftUI.

How @EnvironmentObject Works:

@EnvironmentObject is a property wrapper in SwiftUI that allows you to share an object (like a ViewModel) across multiple views without having to pass it explicitly through each view's initializer. An environment object is typically injected into the environment of a view hierarchy and can then be accessed by any view within that hierarchy.

Example Using @EnvironmentObject:

First, you would make your ViewModel conform to ObservableObject and instantiate it in the parent view or at the app level. Then, inject it into the environment:

@main
struct MyApp: App {
var viewModel = ProductsViewModel()
    var body: some Scene {
WindowGroup {
ProductListView()
.environmentObject(viewModel)
}
}
}

Then in your views, you can access the ViewModel using @EnvironmentObject:

struct ProductListView: View {
@EnvironmentObject var viewModel: ProductsViewModel
// ...
}
struct ProductDetailView: View {
@EnvironmentObject var viewModel: ProductsViewModel
// ...
}

Considerations:

While @EnvironmentObject is powerful, it's important to use it judiciously:

  • Implicit Dependency: Since @EnvironmentObject does not make the dependency explicit in the view's initializer, it can sometimes be less clear where the data is coming from, which can be a drawback in terms of readability and maintainability.
  • Propagation: The object must be injected into the environment before the views are loaded, otherwise the app will crash at runtime when trying to access the environment object.

--

--

No responses yet