Data Binding in SwiftUI: Tips, Tricks, and Best Practices

Want to create dynamic and responsive user interfaces in SwiftUI? Data binding is the key! In this tutorial, I'll show you how to use @State, @ObservedObject, @EnvironmentObject, and @Binding to keep your user interface in sync with your data

Alex Dremov

Are you building an app with SwiftUI and wondering how to manage your app's state? Data binding is a powerful tool that can help you build dynamic and responsive interfaces.

In this tutorial, we'll explore how to use @State, @ObservedObject, and @EnvironmentObject.

What is data binding in SwiftUI?

Data binding connects UI element to a piece of data in your app. When the data changes, the UI element automatically updates to reflect the new value, and when the user interacts with the element, the data updates to reflect the new input.

SwiftUI provides several tools for data binding: @State, @ObservedObject, and @EnvironmentObject. These tools allow you to bind values, objects, and even global objects to your user interface.

How to use @State to bind a simple value to your user interface

@State is a property wrapper that allows you to bind a simple value, like a string or an integer, to your user interface.

💥
Strictly, @State can be used to bind value-type objects only. So, any struct also can be binded using @State.

To use @State, you first define a property with the @State wrapper, and then use the property in your user interface as a usual. For example, here's how you might use @State to bind a string to a text field:

struct ContentView: View {
    @State private var name: String = ""
    
    var body: some View {
        VStack {
            TextField("Enter your name", text: $name)
            Text("Hello, \(name)!")
        }
    }
}

You may notice that $name is used. It allows to access projectedValue of the wrapper. In case of @State it is Binding<Type>.

Now, whenever name is changed, the UI updates automatically. And when the user modifies the text field, variable data gets updated too.

Using @Binding

@Binding is used when you want to bind a value or object that is owned by a different view.

To use @Binding, you first define a property with the @Binding wrapper, and then pass the binding to another view as an argument. The other view can then use the binding to read and write the data from the original view.

struct CustomTextField: View {
    @Binding var text: String
    
    var body: some View {
        HStack {
            Image(systemName: "person.circle")
            TextField("Enter your name", text: $text)
        }
        .padding()
    }
}

struct ContentView: View {
    @State private var name: String = ""
    
    var body: some View {
        VStack {
            CustomTextField(text: $name)
            Text("Hello, \(name)!")
        }
    }
}

You also can pass binding in init using direct access to property wrapper through underscore.

struct CustomTextField: View {
    @Binding var text: String
    
    init(text: Binding<String>) {
        self._text = text
    }
    
    var body: some View {
        HStack {
            Image(systemName: "person.circle")
            TextField("Enter your name", text: $text)
        }
        .padding()
    }
}
💥
You can view @Binding as a channel that gets value from the source and sets value to the source. It does not own an object.

Therefore, @Binding is great for the view decomposition as it allows to inject dependencies to subviews.

Read more about modular app architecture with SwiftUI in my previous post:

iOS App As a Microservice. Using SwiftUI in Modular App
The modular architecture is excellent. But how to implement it effectively with SwiftUI? From its core, SwiftUI is state-driven, and it can be tricky to modularize an app and define exact responsibility borders.

How to use @ObservedObject to bind a class to your user interface

@ObservedObject allows you to bind a class to your user interface. The class must conform to the ObservableObject protocol and use the @Published property wrapper for any properties that you want to bind to your user interface. When the object's @Published properties change, the user interface updates.

Here's an example of how you might use @ObservedObject to bind a User object to a form:

class User: ObservableObject {
    @Published var name: String = ""
    @Published var email: String = ""
    
    var someUntrackedValue = ""
}

struct ContentView: View {
    @ObservedObject private var user = User()
    
    var body: some View {
        VStack {
            TextField("Enter your name", text: $user.name)
            TextField("Enter your email", text: $user.email)
            Text("Hello, \(user.name)!")
        }
    }
}

In this example, the user property is bound to the text fields using the $user.name and $user.email syntax. When the user types in the text fields, the name and email properties of the User object update to reflect the new input, and the Text view updates to show the new value.

💥
Mind that if you publish a reference type in ObservableObject, then changes inside it will not be propagated.
Subscribe and don't miss posts!

How to use @EnvironmentObject to bind a global object to your user interface

EnvironmentObject allows you to bind a global object. The object must conform to the ObservableObject protocol the same way as with @ObservedObject.

@EnvironmentObject is particularly useful when you want to share data across multiple views in your app. For example, you might use @EnvironmentObject to bind a UserSettings object to your app's main view, like this:

class UserSettings: ObservableObject {
    @Published var theme: String = "light"
    @Published var fontSize: Double = 16
}

struct ContentView: View {
    @EnvironmentObject var userSettings: UserSettings
    
    var body: some View {
        VStack {
            if userSettings.theme == "light" {
                Text("Light mode")
            } else {
                Text("Dark mode")
            }
            Text("Font size: \(userSettings.fontSize)")
        }
    }
}

You can pass the @EnvironmentObject down to child views using the environmentObject(_:) modifier. For example:

struct ChildView: View {
    @EnvironmentObject var userSettings: UserSettings
    
    var body: some View {
        Text("Font size: \(userSettings.fontSize)")
    }
}

struct ContentView: View {
    @ObservedObject var userSettings: UserSettings
    
    var body: some View {
        VStack {
            if userSettings.theme == "light" {
                Text("Light mode")
            } else {
                Text("Dark mode")
            }
            ChildView()
        }.environmentObject(userSettings)
    }
}


However, I would suggest not using @EnvironmentObject or only using it on a small scale, as it introduces global dependencies and makes the app's architecture messier. I covered modular architecture principles in one of my previous posts:

iOS App As a Microservice. Build Robust App Architecture
What will you choose: MVVM, MVC, VIPER? Those all are local and problem-specific architectures. But how to structure your app on a larger scale to make it scalable and well-organized?

Best practices for using data binding in SwiftUI

Here are a few best practices for using data binding in SwiftUI:

  1. Use @State for simple values that are specific to a single view.
  2. Use @ObservedObject for complex objects that you need to share with other parts of your app.
  3. Use @EnvironmentObject sparingly, as it can introduce global dependencies and make your app's architecture messier. Only use it when you need to share data across a small portion of your app and there is no cleaner way to do it.
  4. Use @Binding to create custom views with data binding. This allows you to reuse your views and keep your code more modular.
  5. Organize and structure your data bindings in a logical and easy-to-maintain way. Use modular architecture principles to break your app into smaller, more manageable pieces.

By following these best practices, you can create dynamic and responsive user interfaces in SwiftUI that stay in sync with your data and are easy to maintain and test.

Tips for debugging and testing your data bindings

Data binding can be a powerful tool, but it can also be a source of bugs and issues if you're not careful. Here are a few tips for debugging and testing your data bindings:

  1. Make sure that your classes conform to the ObservableObject protocol and use the @Published property wrapper for any properties that you want to bind to your user interface.
  2. Check that you use @State for value type objects and @ObservedObject for reference type.
  3. Check that @Published properties are value types
  4. Use the debugging tools in Xcode to identify and fix issues in your data bindings. You can use the debugger to inspect the values of your bound properties and step through your code to see how the data is flowing through your app.
  5. Use Xcode's preview feature to test your layouts and behaviors in real-time as you build your app. This can be a great way to catch issues with your data bindings early on and ensure that your user interface is working as expected.
  6. Consider using unit tests to validate your data bindings and ensure that they are working correctly. You can use the XCTest framework to write tests that verify the values of your bound properties and check that your user interface is behaving as expected.

Conclusion

Data binding is a powerful tool that allows you to create dynamic and responsive user interfaces that stay in sync with your data.

As you continue to develop your app, remember to keep your data bindings organized and well-structured to ensure that your app is easy to maintain and test. Use modular architecture principles to break your app into smaller, more manageable pieces, and be sure to test your data bindings thoroughly to catch any bugs or issues before you release your app.

With these tips in mind, you'll be well on your way to creating amazing apps with SwiftUI and data binding. Thanks for reading!

See also

iOS App As a Microservice. Build Robust App Architecture
What will you choose: MVVM, MVC, VIPER? Those all are local and problem-specific architectures. But how to structure your app on a larger scale to make it scalable and well-organized?
iOS App As a Microservice. Using SwiftUI in Modular App
The modular architecture is excellent. But how to implement it effectively with SwiftUI? From its core, SwiftUI is state-driven, and it can be tricky to modularize an app and define exact responsibility borders.
iOS App As a Microservice. Modularize Your App With Tuist
This is the second article in a series on modular app architecture. In this post, I will cover implementation details using Tuist

Share

Subscribe to Alex Dremov

Get the email newsletter and receive valuable tips to bump up your professional skills