Skip to content

SwiftUI Data Flow — @State, @Binding, @ObservableObject, @EnvironmentObject

DodaTech Updated 2026-06-28 9 min read

In this tutorial, you will learn about SwiftUI Data Flow. We cover key concepts, practical examples, and best practices to help you master this topic.

SwiftUI data flow is a unidirectional architecture where data flows down from parent to child through bindings and observable objects, and events flow up through callbacks and bindings, ensuring predictable state management.

What You'll Learn

  • @State and @Binding for local state
  • @StateObject and @ObservedObject for model data
  • @EnvironmentObject for Dependency Injection
  • @Published for change notifications
  • Property wrappers comparison and when to use each
  • Building a MVVM Architecture with SwiftUI

Why It Matters

Choosing the wrong property wrapper leads to performance issues, stale data, or crashes. SwiftUI's data flow system is carefully designed: each property wrapper solves a specific problem. Understanding the data flow hierarchy — from local state to shared environment objects — is essential for building apps that scale.

Real-World Use

A note-taking app uses @State for the currently selected note's editing state, @StateObject for the NotesViewModel that fetches and saves notes, @EnvironmentObject for the user's theme and font preferences, and @Binding to pass the note title editing from a detail view back to the view model.

Learning Path

flowchart LR
  A[SwiftUI Basics
Lesson 25] --> B[SwiftUI Data Flow
You are here] B --> C[SwiftUI Navigation
Lesson 27] B --> D[SwiftUI Lists
Lesson 28] style B fill:#f90,color:#fff

@State — Local View State

@State is for simple value types owned by a single view. SwiftUI manages the storage and automatically re-renders the view when the value changes.

import SwiftUI

struct SignupForm: View {
    @State private var email = ""
    @State private var password = ""
    @State private var agreeToTerms = false
    @State private var isSubmitting = false
    @State private var errorMessage: String?

    var isFormValid: Bool {
        !email.isEmpty && email.contains("@") &&
        password.count >= 8 && agreeToTerms
    }

    var body: some View {
        Form {
            Section("Account Details") {
                TextField("Email", text: $email)
                    .keyboardType(.emailAddress)
                    .autocapitalization(.none)

                SecureField("Password", text: $password)

                Toggle("I agree to the terms", isOn: $agreeToTerms)
            }

            Section {
                Button(action: submitForm) {
                    HStack {
                        Spacer()
                        if isSubmitting {
                            ProgressView()
                        } else {
                            Text("Sign Up")
                        }
                        Spacer()
                    }
                }
                .disabled(!isFormValid || isSubmitting)
            }

            if let error = errorMessage {
                Section {
                    Text(error)
                        .foregroundColor(.red)
                        .font(.caption)
                }
            }
        }
        .navigationTitle("Create Account")
    }

    private func submitForm() {
        isSubmitting = true
        errorMessage = nil

        DispatchQueue.main.asyncAfter(deadline: .now() + 1) {
            isSubmitting = false
            if email == "test@test.com" {
                errorMessage = "Email already registered"
            } else {
                print("Signed up with \(email)")
            }
        }
    }
}

@State properties are private and should be marked with private. They store simple value types: strings, booleans, integers, enums.

@StateObject — View Model Ownership

@StateObject is for reference types (ObservableObject) that the view creates and owns. The object lives for the lifetime of the view.

import SwiftUI
import Combine

class TimerViewModel: ObservableObject {
    @Published var secondsElapsed = 0
    @Published var isRunning = false

    private var timer: AnyCancellable?

    func start() {
        guard !isRunning else { return }
        isRunning = true
        secondsElapsed = 0

        timer = Timer.publish(every: 1, on: .main, in: .common)
            .autoconnect()
            .sink { [weak self] _ in
                self?.secondsElapsed += 1
            }
    }

    func stop() {
        isRunning = false
        timer?.cancel()
        timer = nil
    }

    func reset() {
        stop()
        secondsElapsed = 0
    }

    var formattedTime: String {
        let minutes = secondsElapsed / 60
        let seconds = secondsElapsed % 60
        return String(format: "%02d:%02d", minutes, seconds)
    }

    deinit {
        timer?.cancel()
    }
}

struct TimerView: View {
    @StateObject private var viewModel = TimerViewModel()

    var body: some View {
        VStack(spacing: 20) {
            Text(viewModel.formattedTime)
                .font(.system(size: 60, design: .monospaced))
                .padding()

            HStack(spacing: 20) {
                Button(viewModel.isRunning ? "Pause" : "Start") {
                    viewModel.isRunning ? viewModel.stop() : viewModel.start()
                }
                .buttonStyle(.borderedProminent)

                Button("Reset") {
                    viewModel.reset()
                }
                .buttonStyle(.bordered)
            }
        }
        .padding()
    }
}

The @StateObject is created once when the view appears and persists across re-renders. When the view is removed from the hierarchy, the view model is deallocated.

@ObservedObject — Shared View Model

@ObservedObject is for observable objects that the view does not own — passed in from a parent.

import SwiftUI

class ShoppingCart: ObservableObject {
    @Published var items: [CartItem] = []
    @Published var discountCode: String = ""

    var totalPrice: Double {
        let subtotal = items.reduce(0) { $0 + $1.price * Double($1.quantity) }
        let discount = discountCode == "SAVE10" ? subtotal * 0.1 : 0
        return subtotal - discount
    }

    var itemCount: Int {
        items.reduce(0) { $0 + $1.quantity }
    }

    func addItem(_ item: CartItem) {
        if let index = items.firstIndex(where: { $0.id == item.id }) {
            items[index].quantity += item.quantity
        } else {
            items.append(item)
        }
    }

    func removeItem(at offsets: IndexSet) {
        items.remove(atOffsets: offsets)
    }
}

struct CartItem: Identifiable {
    let id = UUID()
    let name: String
    let price: Double
    var quantity: Int
}

struct CartView: View {
    @ObservedObject var cart: ShoppingCart

    var body: some View {
        VStack {
            List {
                ForEach(cart.items) { item in
                    HStack {
                        Text(item.name)
                        Spacer()
                        Text("\(item.quantity)x")
                        Text("$\(item.price * Double(item.quantity), specifier: "%.2f")")
                    }
                }
                .onDelete { cart.removeItem(at: $0) }

                if !cart.items.isEmpty {
                    HStack {
                        Text("Total:")
                            .fontWeight(.bold)
                        Spacer()
                        Text("$\(cart.totalPrice, specifier: "%.2f")")
                            .fontWeight(.bold)
                    }
                }
            }

            if cart.items.isEmpty {
                Text("Cart is empty")
                    .foregroundColor(.secondary)
            }
        }
        .navigationTitle("Cart (\(cart.itemCount))")
    }
}

struct CartParentView: View {
    @StateObject private var cart = ShoppingCart()

    var body: some View {
        CartView(cart: cart)

        Button("Add Sample Items") {
            cart.addItem(CartItem(name: "Book", price: 19.99, quantity: 1))
            cart.addItem(CartItem(name: "Pen", price: 2.99, quantity: 3))
        }
    }
}

The parent creates and owns the ShoppingCart with @StateObject. The child CartView receives it with @ObservedObject.

@EnvironmentObject — Dependency Injection

@EnvironmentObject injects shared data through the view hierarchy without passing it explicitly through each view.

import SwiftUI

class AppSettings: ObservableObject {
    @Published var isDarkMode = false
    @Published var accentColor: Color = .blue
    @Published var fontSize: FontSize = .medium

    enum FontSize: String, CaseIterable {
        case small, medium, large

        var scale: Double {
            switch self {
            case .small: return 0.85
            case .medium: return 1.0
            case .large: return 1.2
            }
        }
    }
}

struct EnvironmentParentView: View {
    @StateObject private var settings = AppSettings()

    var body: some View {
        TabView {
            NavigationStack {
                HomeView()
            }
            .tabItem {
                Label("Home", systemImage: "house")
            }

            NavigationStack {
                SettingsView()
            }
            .tabItem {
                Label("Settings", systemImage: "gear")
            }
        }
        .environmentObject(settings)
        .preferredColorScheme(settings.isDarkMode ? .dark : .light)
    }
}

struct HomeView: View {
    @EnvironmentObject private var settings: AppSettings

    var body: some View {
        VStack(spacing: 16) {
            Text("Welcome!")
                .font(.system(size: 24 * settings.fontSize.scale))

            Text("Current accent color:")
            RoundedRectangle(cornerRadius: 8)
                .fill(settings.accentColor)
                .frame(width: 60, height: 60)

            Text("Theme: \(settings.isDarkMode ? "Dark" : "Light")")
        }
        .padding()
        .navigationTitle("Home")
    }
}

struct SettingsView: View {
    @EnvironmentObject private var settings: AppSettings

    var body: some View {
        Form {
            Section("Appearance") {
                Toggle("Dark Mode", isOn: $settings.isDarkMode)

                Picker("Font Size", selection: $settings.fontSize) {
                    ForEach(AppSettings.FontSize.allCases, id: \.self) { size in
                        Text(size.rawValue.capitalized).tag(size)
                    }
                }

                ColorPicker("Accent Color", selection: $settings.accentColor)
            }
        }
        .navigationTitle("Settings")
    }
}

.environmentObject(settings) injects the settings into the entire tab view hierarchy. Any view can access it with @EnvironmentObject.

@Published — Change Notification

@Published is a property wrapper inside ObservableObject that creates a publisher for a property. When the property changes, all observing views re-render.

import SwiftUI

class SearchViewModel: ObservableObject {
    @Published var query = "" {
        didSet {
            performSearch()
        }
    }
    @Published var results: [String] = []
    @Published var isSearching = false

    private let allItems = ["Apple", "Banana", "Cherry", "Date", "Fig",
                            "Grape", "Kiwi", "Lemon", "Mango", "Orange"]

    private func performSearch() {
        guard query.count >= 2 else {
            results = []
            return
        }

        isSearching = true

        DispatchQueue.main.asyncAfter(deadline: .now() + 0.3) { [weak self] in
            guard let self = self else { return }
            self.results = self.allItems.filter {
                $0.localizedCaseInsensitiveContains(self.query)
            }
            self.isSearching = false
        }
    }
}

struct SearchView: View {
    @StateObject private var viewModel = SearchViewModel()

    var body: some View {
        VStack {
            HStack {
                Image(systemName: "magnifyingglass")
                    .foregroundColor(.gray)
                TextField("Search fruits...", text: $viewModel.query)
                if viewModel.isSearching {
                    ProgressView()
                }
            }
            .padding(10)
            .background(Color.gray.opacity(0.1))
            .cornerRadius(10)
            .padding(.horizontal)

            List(viewModel.results, id: \.self) { item in
                Text(item)
            }
            .listStyle(.plain)
        }
    }
}

Property Wrappers Comparison

Wrapper Ownership Type Lifetime Use Case
@State View owns it Value types View's lifetime TextField text, toggle state, counters
@Binding View does not own Reference to state Same as source Child views needing to modify parent state
@StateObject View owns it ObservableObject View's lifetime ViewModel created by this view
@ObservedObject View does not own ObservableObject Parent's lifetime Shared ViewModel passed from parent
@EnvironmentObject Injected ObservableObject App/section lifetime Global settings, auth state, theme

Common Mistakes

  1. Using @ObservedObject instead of @StateObject: When a view creates its ObservableObject, use @StateObject. @ObservedObject is for objects passed from a parent.

  2. Marking @State properties as non-private: @State should always be private. The view owns this state exclusively.

  3. Not using @Published for observable properties: Properties of an ObservableObject that should trigger view updates must be marked @Published.

  4. Creating ObservableObject in child views: ObservableObjects should be created higher in the hierarchy and passed down, not created in every leaf view.

  5. Forgetting .environmentObject modifier: Using @EnvironmentObject without injecting the object via .environmentObject() causes a runtime crash.

Practice Questions

  1. What is the difference between @StateObject and @ObservedObject?
  2. When should you use @EnvironmentObject instead of passing data through initializers?
  3. What does @Published do inside an ObservableObject?
  4. Why must @State properties be private?
  5. Challenge: Build a ThemeManager ObservableObject that stores darkMode, accentColor, and fontSize. Inject it as an environment object. Create three views at different depth levels that all read and write different theme properties.

Mini Project

Create a TodoApp with:

  • A TodoViewModel ObservableObject with @Published todos array and filter state
  • A TodoListView using @StateObject for the viewModel
  • A AddTodoView with @Binding to dismiss and add items
  • A FilterBarView using @EnvironmentObject for a shared filter state
  • A StatsView showing completed/total counts
  • Proper communication between all views using the correct property wrappers

FAQ

Can I use @State with reference types?

Technically yes, but SwiftUI tracks the reference, not the object's changes. Use @StateObject or @ObservedObject for reference types.

What happens if I use @ObservedObject but forget @StateObject?

If the parent recreates the object on every render, the child's @ObservedObject gets a new instance, losing all state. This causes bugs that are hard to debug.

Can I have multiple @StateObject in one view?

Yes. Each @StateObject is independently managed. This is useful when a view composes multiple independent view models.

How do I pass data to a deeply nested view?

Use @EnvironmentObject for data that many views need, or pass through initializers for data that only specific children need.

Is ObservableObject the same as ViewModel in MVVM?

Yes. ObservableObject is SwiftUI's way of implementing the ViewModel pattern. The view observes the ObservableObject and updates when @Published properties change.

What's Next

Learn screen-to-screen navigation with SwiftUI Navigation, or explore building data-driven lists in SwiftUI Lists.

Built by the developers of DodaTech

Doda Browser, DodaZIP & Durga Antivirus Pro