Dependency Injection in SwiftUI: Composing Views Without Singletons

One of the most common pitfalls in SwiftUI apps is the overuse of singletons and global state. It feels convenient at first — you just reach for UserDefaults.standard, a shared NetworkService.shared, or a global @EnvironmentObject — but it quietly makes your code untestable, hard to reason about, and tightly coupled.
This article shows how to apply Dependency Injection (DI) in SwiftUI to keep your views composable, your ViewModels testable, and your architecture clean.
The Problem: Hidden Dependencies
Let's say you're building a simple favorites feature. A common approach looks like this:
class FavoritesViewModel: ObservableObject {
@Published var favorites: [Item] = []
func loadFavorites() {
// Directly coupled to a concrete singleton
favorites = FavoritesStore.shared.getAll()
}
}
And the view:
struct FavoritesView: View {
@StateObject private var viewModel = FavoritesViewModel()
var body: some View {
List(viewModel.favorites) { item in
Text(item.name)
}
.onAppear { viewModel.loadFavorites() }
}
}
This works at runtime. But try writing a unit test for FavoritesViewModel:
You can't control what
FavoritesStore.sharedreturns.Tests become dependent on shared mutable state.
Running tests in parallel risks interference between test cases.
The dependency on FavoritesStore.shared is hidden inside the ViewModel. Hidden dependencies are a form of technical debt that compounds over time.
The Solution: Inject Dependencies Through the Initializer
The fix is to invert the dependency: instead of the ViewModel reaching out for what it needs, we give it what it needs.
Start by defining a protocol that abstracts the store:
protocol FavoritesStoreProtocol {
func getAll() -> [Item]
}
Make the real store conform to it:
class FavoritesStore: FavoritesStoreProtocol {
static let shared = FavoritesStore()
func getAll() -> [Item] {
// real implementation
}
}
Now inject the dependency into the ViewModel:
class FavoritesViewModel: ObservableObject {
@Published var favorites: [Item] = []
private let store: FavoritesStoreProtocol
init(store: FavoritesStoreProtocol) {
self.store = store
}
func loadFavorites() {
favorites = store.getAll()
}
}
The ViewModel no longer knows (or cares) how favorites are retrieved. It only knows what to call.
Making the View Composable
With DI in place, the view needs to receive a configured ViewModel:
struct FavoritesView: View {
@StateObject private var viewModel: FavoritesViewModel
init(viewModel: FavoritesViewModel) {
_viewModel = StateObject(wrappedValue: viewModel)
}
var body: some View {
List(viewModel.favorites) { item in
Text(item.name)
}
.onAppear { viewModel.loadFavorites() }
}
}
The Composition Root — where you wire everything together — lives at the entry point of your app:
@main
struct FavoritesApp: App {
var body: some Scene {
WindowGroup {
FavoritesView(
viewModel: FavoritesViewModel(store: FavoritesStore.shared)
)
}
}
}
Notice that FavoritesStore.shared appears only once, at the top. The rest of the codebase never references it directly.
Writing Tests Without Infrastructure
Now you can test the ViewModel in complete isolation with a stub:
class FavoritesStoreStub: FavoritesStoreProtocol {
var stubbedFavorites: [Item] = []
func getAll() -> [Item] {
stubbedFavorites
}
}
final class FavoritesViewModelTests: XCTestCase {
func test_loadFavorites_populatesListWithStoredItems() {
let store = FavoritesStoreStub()
store.stubbedFavorites = [Item(name: "SwiftUI"), Item(name: "TDD")]
let sut = FavoritesViewModel(store: store)
sut.loadFavorites()
XCTAssertEqual(sut.favorites.map(\.name), ["SwiftUI", "TDD"])
}
func test_loadFavorites_withEmptyStore_producesEmptyList() {
let store = FavoritesStoreStub()
let sut = FavoritesViewModel(store: store)
sut.loadFavorites()
XCTAssertTrue(sut.favorites.isEmpty)
}
}
These tests are:
Fast — no network, no disk, no shared state.
Deterministic — the stub always returns what you configure.
Isolated — each test controls its own world.
Passing Dependencies Through the View Hierarchy
For deeper view hierarchies, you have two clean options.
Option 1: Explicit Passing (Preferred for Testability)
Pass dependencies through initializers at every level. It's verbose, but the dependency graph is explicit and auditable.
struct RootView: View {
let store: FavoritesStoreProtocol
var body: some View {
NavigationStack {
FavoritesView(
viewModel: FavoritesViewModel(store: store)
)
}
}
}
Option 2: @EnvironmentObject with a Protocol Wrapper
When explicit passing becomes impractical (e.g. very deep hierarchies), @EnvironmentObject is acceptable — but inject at the Composition Root, not a singleton:
// Define a concrete container
class AppDependencies: ObservableObject {
let store: FavoritesStoreProtocol
init(store: FavoritesStoreProtocol) {
self.store = store
}
}
// Inject once at the root
@main
struct FavoritesApp: App {
var body: some Scene {
WindowGroup {
RootView()
.environmentObject(AppDependencies(store: FavoritesStore.shared))
}
}
}
The key difference from a singleton: you control what goes into AppDependencies. In tests, you inject a stub version. No global mutable state.
Previews for Free
A nice side effect of DI: Xcode Previews become trivial to configure.
#Preview {
let store = FavoritesStoreStub()
store.stubbedFavorites = [
Item(name: "Dependency Injection"),
Item(name: "TDD"),
Item(name: "Clean Architecture")
]
return FavoritesView(viewModel: FavoritesViewModel(store: store))
}
No more fighting Previews because they hit the real network or real database.
What You Gain
| Before (Singleton) | After (DI) |
|---|---|
| Hidden dependencies | Explicit, visible dependencies |
| Hard to test | Fully unit-testable |
| Shared mutable state in tests | Each test controls its own world |
| Previews require real infrastructure | Previews use stubs |
| Tight coupling | Views and ViewModels are composable |
Summary
Dependency Injection is not a complex pattern — it's simply the discipline of giving a component what it needs rather than letting it reach out for it. In SwiftUI:
Define protocols for your dependencies.
Inject concrete implementations through initializers.
Wire everything at the Composition Root (your
@mainstruct).Use stubs in tests and previews.
This one practice unlocks testability, composability, and clear architecture — all at once. It's the foundation on which Clean Architecture, the Decorator pattern, and every other technique rely on.





