SwiftUI Data Flow Patterns: From Single View to Multi-Screen Architecture
Building a single SwiftUI view is easy. Building an app with 15 screens, shared state, navigation, and real data is where developers stall — every architectural decision fights you as complexity grows.
This post is a practical decision guide for SwiftUI data flow. It covers the five core patterns, when each applies, and the two failure modes that cause the most pain on real projects. It does not cover navigation state, which has its own dedicated post, or SwiftData integration.
This guide assumes you’re comfortable with SwiftUI state management and
have read the Observation framework post — we’ll use @Observable throughout.
Contents
- The Problem: Two Failure Modes
- Pattern 1: @State for Local Ephemeral State
- Pattern 2: @Binding for Parent-Owned Mutable State
- Pattern 3: @Observable View Models for Screen Logic
- Pattern 4: @Environment for Shared Dependencies
- Pattern 5: Navigation State as Data
- Advanced Usage
- When to Use (and When Not To)
- Summary
The Problem: Two Failure Modes
Real SwiftUI codebases tend to collapse in one of two directions.
Failure mode 1 — Prop drilling. A deeply nested child view needs to mutate state owned by an ancestor. The developer
threads a @Binding through every intermediate view that doesn’t actually care about the data. Five layers of
FilmListView(selectedFilm: $selectedFilm) later, even simple refactors become expensive:
// Prop drilling — each view passes state it doesn't use
struct ContentView: View {
@State private var selectedCharacter: PixarCharacter?
var body: some View {
FilmListView(selectedCharacter: $selectedCharacter)
}
}
struct FilmListView: View {
@Binding var selectedCharacter: PixarCharacter? // Doesn't use this
var body: some View {
FilmDetailView(selectedCharacter: $selectedCharacter)
}
}
struct FilmDetailView: View {
@Binding var selectedCharacter: PixarCharacter? // Doesn't use this
var body: some View {
CharacterListView(selectedCharacter: $selectedCharacter)
}
}
struct CharacterListView: View {
@Binding var selectedCharacter: PixarCharacter? // Finally uses it
var body: some View {
ForEach(characters) { character in
Button(character.name) { selectedCharacter = character }
}
}
}
Failure mode 2 — The God Object. The developer overcorrects by putting everything in one @Observable class
injected into the environment. Every screen reads the same model, business logic leaks into views through method calls,
and testing becomes impossible without constructing the entire app state:
// God object — one model to rule them all
@Observable @MainActor
final class AppState {
var films: [PixarFilm] = []
var characters: [PixarCharacter] = []
var isAuthenticated: Bool = false
var selectedFilm: PixarFilm?
var currentUser: User?
var searchQuery: String = ""
var isLoading: Bool = false
var errorMessage: String?
// ... 20 more properties
}
Every view in the app now has access to isAuthenticated, currentUser, and the entire film catalog simultaneously.
When searchQuery changes, views displaying characters re-evaluate unnecessarily. Testing a character detail screen
requires seeding an entire AppState. The codebase is tightly coupled at the data layer.
The solution is not a single pattern — it’s using the right pattern for each data ownership scenario.
Pattern 1: @State for Local Ephemeral State
@State is the right tool when the data belongs exclusively
to one view and resets when the view disappears. Classic examples: whether a sheet is presented, the current value of a
text field, an animation progress value, the selected tab index within a view.
struct FilmSearchView: View {
@State private var query: String = ""
@State private var isFilterSheetPresented: Bool = false
var body: some View {
VStack {
TextField("Search Pixar films…", text: $query)
Button("Filters") { isFilterSheetPresented = true }
}
.sheet(isPresented: $isFilterSheetPresented) {
FilterSheetView()
}
}
}
query and isFilterSheetPresented are transient — they don’t belong to any model, they have no meaning outside this
view, and they’ll be discarded when the view is removed from the hierarchy. This is exactly what @State is designed
for.
Tip: Keep
@Statepropertiesprivate. If another view needs to read or modify the value, that’s a signal the data should move up the hierarchy — to a@Binding, a view model, or the environment.
Pattern 2: @Binding for Parent-Owned Mutable State
@Binding gives a child view a reference to state owned
by its parent. The data lives in exactly one place (the parent’s @State or view model), and the child gets a write
channel into it. Use this for reusable components — pickers, form rows, custom controls — that need to modify their
caller’s data.
struct FilmRatingRow: View {
let film: PixarFilm
@Binding var rating: Double // Parent owns this value
var body: some View {
HStack {
Text(film.title)
Spacer()
Slider(value: $rating, in: 0...5, step: 0.5)
.frame(width: 120)
Text(String(format: "%.1f", rating))
.monospacedDigit()
}
}
}
// Usage
struct FilmReviewView: View {
@State private var buzzRating: Double = 0
@State private var woodyRating: Double = 0
var body: some View {
List {
FilmRatingRow(film: .toyStory, rating: $buzzRating)
FilmRatingRow(film: .toyStory2, rating: $woodyRating)
}
}
}
The key constraint: @Binding is for short chains — parent to direct child, occasionally to grandchild. If you’re
passing a binding three or more levels deep, you’ve crossed into prop drilling territory and should reach for Pattern 3
or 4 instead.
Pattern 3: @Observable View Models for Screen Logic
Each screen (a full-screen view or a major tab) should own a view model — an @Observable class that holds the screen’s
business logic, async operations, and display state. The view delegates all data loading and mutation to its view model.
@Observable @MainActor
final class FilmDetailViewModel {
var film: PixarFilm
var isFavorite: Bool = false
var relatedFilms: [PixarFilm] = []
var isLoadingRelated: Bool = false
var errorMessage: String?
private let repository: any FilmRepository
init(film: PixarFilm, repository: any FilmRepository) {
self.film = film
self.repository = repository
}
func toggleFavorite() async {
do {
isFavorite.toggle()
try await repository.updateFavorite(film: film, isFavorite: isFavorite)
} catch {
isFavorite.toggle() // Rollback on failure
errorMessage = error.localizedDescription
}
}
func loadRelatedFilms() async {
isLoadingRelated = true
defer { isLoadingRelated = false }
do {
relatedFilms = try await repository.fetchRelated(to: film)
} catch {
errorMessage = error.localizedDescription
}
}
}
The view becomes a thin rendering layer:
struct FilmDetailView: View {
@State private var viewModel: FilmDetailViewModel
init(film: PixarFilm, repository: any FilmRepository) {
_viewModel = State(wrappedValue: FilmDetailViewModel(
film: film,
repository: repository
))
}
var body: some View {
ScrollView {
VStack(alignment: .leading, spacing: 16) {
Text(viewModel.film.title)
.font(.largeTitle)
Button {
Task { await viewModel.toggleFavorite() }
} label: {
Image(systemName: viewModel.isFavorite ? "heart.fill" : "heart")
}
if viewModel.isLoadingRelated {
ProgressView()
} else {
ForEach(viewModel.relatedFilms) { film in
Text(film.title)
}
}
}
}
.task { await viewModel.loadRelatedFilms() }
.alert("Error", isPresented: .constant(viewModel.errorMessage != nil)) {
Button("OK") { viewModel.errorMessage = nil }
} message: {
Text(viewModel.errorMessage ?? "")
}
}
}
This approach has three payoffs: the view model is trivially testable (inject a mock FilmRepository), the view is
purely a rendering function, and fine-grained observation means only the relevant view re-renders when
isLoadingRelated changes — not all views observing the model.
Note: Use
@State(not a stored property) to own the view model in the view.@Stateensures SwiftUI creates exactly one instance per view lifetime and releases it when the view is removed. Using a plain stored property risks recreating the model on every render.
Pattern 4: @Environment for Shared Dependencies
@Environment is SwiftUI’s dependency injection
mechanism. Use it for values that many screens need but no single screen owns: the authentication session, a shared
service locator, a theme configuration, a repository.
Define a custom EnvironmentKey and extend EnvironmentValues:
// FilmRepository.swift
protocol FilmRepository: Sendable {
func fetchAll() async throws -> [PixarFilm]
func fetchRelated(to film: PixarFilm) async throws -> [PixarFilm]
func updateFavorite(film: PixarFilm, isFavorite: Bool) async throws
}
private struct FilmRepositoryKey: EnvironmentKey {
static let defaultValue: any FilmRepository = LiveFilmRepository()
}
extension EnvironmentValues {
var filmRepository: any FilmRepository {
get { self[FilmRepositoryKey.self] }
set { self[FilmRepositoryKey.self] = newValue }
}
}
Inject at the root and consume anywhere:
// At the app root — inject the live implementation
WindowGroup {
ContentView()
.environment(\.filmRepository, LiveFilmRepository())
}
// In tests or previews — inject a mock
FilmDetailView(film: .coco)
.environment(\.filmRepository, MockFilmRepository())
// Bridge the environment to the view model in the view
struct FilmDetailView: View {
@Environment(\.filmRepository) private var repository
@State private var viewModel: FilmDetailViewModel?
let film: PixarFilm
var body: some View {
Group {
if let viewModel {
FilmDetailContent(viewModel: viewModel)
}
}
.onAppear {
viewModel = FilmDetailViewModel(film: film, repository: repository)
}
}
}
Warning: Do not inject
@Observablemodels containing screen-specific state into the environment just to avoid passing them as parameters. That recreates the God Object anti-pattern. Reserve the environment for services (repositories, analytics loggers, auth clients) and configuration (themes, locale). Screen state belongs in per-screen view models.
Pattern 5: Navigation State as Data
SwiftUI’s NavigationStack with
NavigationPath makes navigation state a value you
can read, write, and serialize. Full coverage of navigation architecture is in
Navigation Architecture in SwiftUI, but the data flow principle is worth stating
here: navigation is state, and it should be owned and managed like any other state.
A coordinator pattern — an @Observable class owning a NavigationPath — integrates cleanly with the patterns above:
@Observable @MainActor
final class AppCoordinator {
var path = NavigationPath()
func showFilmDetail(_ film: PixarFilm) {
path.append(film)
}
func popToRoot() {
path.removeLast(path.count)
}
}
The coordinator is injected into the environment and consumed by any view that needs to trigger navigation, eliminating the need to thread callbacks through the view hierarchy.
Advanced Usage
Shared State Between Unrelated Screens
Two screens that are not in a parent-child relationship but need to share mutable state — a shopping cart, a draft
message, a background upload progress — should share an @Observable model through the environment. The key is scoping:
the shared model should only hold the state that genuinely needs to be shared. Everything else stays in per-screen view
models.
@Observable @MainActor
final class WatchlistStore {
var watchlist: [PixarFilm] = []
func add(_ film: PixarFilm) {
guard !watchlist.contains(where: { $0.id == film.id }) else { return }
watchlist.append(film)
}
func remove(_ film: PixarFilm) {
watchlist.removeAll { $0.id == film.id }
}
}
// Inject at the root
WindowGroup {
ContentView()
.environment(WatchlistStore())
}
// Consume in any screen
struct FilmDetailView: View {
@Environment(WatchlistStore.self) private var watchlist
let film: PixarFilm
var body: some View {
Button {
watchlist.add(film)
} label: {
Label("Add to Watchlist", systemImage: "plus")
}
}
}
Testing Data Flow with Mock Repositories
The @Environment-based dependency injection pattern makes testing straightforward. Mock your repository conformance
and inject it in the test:
final class MockFilmRepository: FilmRepository {
var filmsToReturn: [PixarFilm] = []
var shouldThrow: Bool = false
func fetchAll() async throws -> [PixarFilm] {
if shouldThrow { throw URLError(.badServerResponse) }
return filmsToReturn
}
func fetchRelated(to film: PixarFilm) async throws -> [PixarFilm] {
return filmsToReturn
}
func updateFavorite(film: PixarFilm, isFavorite: Bool) async throws {}
}
// In a test
func testLoadRelatedFilmsPopulatesViewModel() async throws {
let mock = MockFilmRepository()
mock.filmsToReturn = [.toyStory, .findingNemo]
let viewModel = FilmDetailViewModel(film: .coco, repository: mock)
await viewModel.loadRelatedFilms()
XCTAssertEqual(viewModel.relatedFilms.count, 2)
XCTAssertFalse(viewModel.isLoadingRelated)
}
No SwiftUI, no XCTestExpectation, no view hosting required. The logic is in the view model; the test is direct and
fast.
When to Use (and When Not To)
| Scenario | Pattern | Reason |
|---|---|---|
| Sheet presentation flag | @State | Ephemeral, belongs to one view |
| Reusable form row that writes data | @Binding | Child writes to parent-owned value |
| Passing state 3+ levels deep | View model + environment | Threading bindings is fragile |
| Screen business logic, async ops | @Observable view model | Keeps views thin; enables testing |
| Shared services (repository, auth) | @Environment custom key | Clean DI; swappable in tests |
| Cross-screen shared mutable state | @Observable in environment | Single source of truth |
| Global God Object | Avoid | Coupling, kills testability |
@Binding to @Observable property | @Bindable | Required to vend $ bindings |
Decision Flowchart
Does only one view need this data?
YES → @State (ephemeral) or @Observable view model (business logic)
NO → Does a child view need to write back to a parent's state?
YES → @Binding (1–2 levels) or view model (deeper)
NO → Do multiple unrelated screens need this data?
YES → @Observable model in @Environment
NO → Is this a service/dependency?
YES → @Environment with custom EnvironmentKey
NO → Re-evaluate: likely @Observable view model
Summary
- Two failure modes dominate real SwiftUI codebases: prop drilling and the God Object.
@Stateowns transient, single-view data. Keep itprivate.@Bindinggives a child a write channel into parent-owned state. Stop at 1–2 levels.@Observableview models own screen-level business logic and async operations. One view model per screen keeps views testable and thin.@Environmentis SwiftUI’s DI container. Use it for services and cross-cutting shared state — not for screen-specific view models.- Fine-grained observation from
@Observablemakes the environment safe for shared models: only views reading a mutated property re-render.
Navigation state is the next dimension of this architecture. Read
Navigation Architecture in SwiftUI to see how NavigationPath and coordinators
complete the picture.