SwiftUI Data Flow — @State, @Binding, @ObservableObject, @EnvironmentObject
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
Using @ObservedObject instead of @StateObject: When a view creates its ObservableObject, use @StateObject. @ObservedObject is for objects passed from a parent.
Marking @State properties as non-private: @State should always be private. The view owns this state exclusively.
Not using @Published for observable properties: Properties of an ObservableObject that should trigger view updates must be marked @Published.
Creating ObservableObject in child views: ObservableObjects should be created higher in the hierarchy and passed down, not created in every leaf view.
Forgetting .environmentObject modifier: Using @EnvironmentObject without injecting the object via
.environmentObject()causes a runtime crash.
Practice Questions
- What is the difference between @StateObject and @ObservedObject?
- When should you use @EnvironmentObject instead of passing data through initializers?
- What does @Published do inside an ObservableObject?
- Why must @State properties be private?
- Challenge: Build a
ThemeManagerObservableObject 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
TodoViewModelObservableObject with @Published todos array and filter state - A
TodoListViewusing @StateObject for the viewModel - A
AddTodoViewwith @Binding to dismiss and add items - A
FilterBarViewusing @EnvironmentObject for a shared filter state - A
StatsViewshowing completed/total counts - Proper communication between all views using the correct property wrappers
FAQ
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