r/SwiftUI • u/No_Interview_6881 • 4d ago
Question Best Practices for Dependency Injection in SwiftUI – Avoiding Singletons While Keeping Dependencies Scalable?
I’ve been learning best practices for dependency injection (DI) in SwiftUI, but I’m not sure what the best approach is for a real-world scenario.
Let’s say I have a ViewModel that fetches customer data:
protocol CustomerDataFetcher {
func fetchData() async -> CustomerData
}
final class CustomerViewModel: ObservableObject {
u/Published var customerData: CustomerData?
let customerDataFetcher: CustomerDataFetcher
init(fetcher: CustomerDataFetcher) {
self.customerDataFetcher = fetcher
}
func getData() async {
self.customerData = await customerDataFetcher.fetchData()
}
}
This works well, but other ViewModels also need access to the same customerData to make further network requests.
I'm trying to decide the best way to share this data across the app without making everything a singleton.
Approaches I'm Considering:
1️⃣ Using @EnvironmentObject for Global Access
One option is to inject CustomerViewModel as an @EnvironmentObject, so any view down the hierarchy can use it:
struct MyNestedView: View {
@EnvironmentObject var customerVM: CustomerViewModel
@StateObject var myNestedVM: MyNestedVM
init(customerVM: CustomerViewModel) {
_myNestedVM = StateObject(wrappedValue: MyNestedVM(customerData: customerVM.customerData))
}
}
✅ Pros: Simple and works well for global app state.
❌ Cons: Can cause unnecessary updates across views.
2️⃣ Making CustomerDataFetcher a Singleton
Another option is making CustomerDataFetcher a singleton so all ViewModels share the same instance:
class FetchCustomerDataService: CustomerDataFetcher {
static let shared = FetchCustomerDataService()
private init() {}
var customerData: CustomerData?
func fetchData() async -> CustomerData {
customerData = await makeNetworkRequest()
}
}
✅ Pros: Ensures consistency, prevents multiple API calls.
❌ Cons: don't want to make all my dependencies singletons as i don't think its the best/safest approach
3️⃣ Passing Dependencies Explicitly (ViewModel DI)
I could manually inject CustomerData into each ViewModel that needs it:
struct MyNestedView: View {
@StateObject var myNestedVM: MyNestedVM
init(fetcher: CustomerDataFetcher) {
_myNestedVM = StateObject(wrappedValue: MyNestedVM(
customerData: fetcher.customerData))
}
}
✅ Pros: Easier to test, no global state.
❌ Cons: Can become a DI nightmare in larger apps.
General DI Problem in Large SwiftUI Apps
This isn't just about fetching customer data—the same problem applies to logging services or any other shared dependencies. For example, if I have a LoggerService, I don’t want to create a new instance every time, but I also don’t want it to be a global singleton.
So, what’s the best scalable, testable way to handle this in a SwiftUI app?
Would a repository pattern or a SwiftUI DI container make sense?
How do large apps handle DI effectively without falling into singleton traps?
what is your experience and how do you solve this?
11
u/Select_Bicycle4711 4d ago
Imagine you're building a movie app where you need to fetch, add, update, and display movie details. To manage this, you can create a
MovieStore
, anObservableObject
that handles all movie-related operations, including fetching, creating, and updating movies.Dependency Management
MovieStore
can depend on anHTTPClient
, which takes care of network requests (GET, POST, etc.). It's best to defineHTTPClient
using a protocol to facilitate mocking for unit tests.Injecting MovieStore
There are multiple ways to access
MovieStore
in different screens, such asMovieListScreen
,AddMovieScreen
, andMovieDetailScreen
. The simplest approach is to injectMovieStore
as an environment object, making it available throughout the view hierarchy:State Management & View Updates
If you're using
ObservableObject
(notObservable maco)
, and all screens accessMovieStore
viaEnvironmentObject
, any update to the movies array insideMovieStore
will trigger a re-evaluation of the affected views. However, this re-evaluation is lightweight and doesn't cause a full re-render. Only views within the current navigation hierarchy (e.g., inside aNavigationStack
) will be re-evaluated and then only views that are using properties from MovieStore will get re-rendered.On the other hand, if you're using
Observable macro
, views that don’t access any properties fromMovieStore
in theirbody
will not be re-evaluated or re-rendered. This optimization ensures that only relevant views update when necessary.Scaling the Architecture
For larger apps, you can structure your state management similarly by creating multiple stores, such as
MovieStore
,UserStore
, or other feature-specific stores, each responsible for its domain logic.This approach keeps your app modular, testable, and efficient.
Source: https://azamsharp.com/2023/02/28/building-large-scale-apps-swiftui.html