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

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 @State properties private. 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. @State ensures 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 @Observable models 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)

ScenarioPatternReason
Sheet presentation flag@StateEphemeral, belongs to one view
Reusable form row that writes data@BindingChild writes to parent-owned value
Passing state 3+ levels deepView model + environmentThreading bindings is fragile
Screen business logic, async ops@Observable view modelKeeps views thin; enables testing
Shared services (repository, auth)@Environment custom keyClean DI; swappable in tests
Cross-screen shared mutable state@Observable in environmentSingle source of truth
Global God ObjectAvoidCoupling, kills testability
@Binding to @Observable property@BindableRequired 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.
  • @State owns transient, single-view data. Keep it private.
  • @Binding gives a child a write channel into parent-owned state. Stop at 1–2 levels.
  • @Observable view models own screen-level business logic and async operations. One view model per screen keeps views testable and thin.
  • @Environment is SwiftUI’s DI container. Use it for services and cross-cutting shared state — not for screen-specific view models.
  • Fine-grained observation from @Observable makes 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.