SwiftUI provides a powerful and declarative way to build UIs, allowing views to react to state changes automatically. However, managing communication between views, especially when passing data or events from one view to another, can be challenging if not handled properly. In this article, we’ll explore several communication patterns in SwiftUI that enable seamless data flow between views, ensuring that updates occur efficiently and in a way that aligns with SwiftUI’s architecture.
We will dive into a practical scenario: starting from a list screen, where a user can tap a button to open a sheet to add a new item. The focus of the article will be on different approaches that allow the newly added item to communicate back to the list screen and update it accordingly. By exploring these patterns—such as closures, bindings, and @Environment
objects—you’ll learn best practices for ensuring your SwiftUI views interact harmoniously, without workarounds like boolean flags to force view refreshes.
One of the simplest approaches to communication between views is using closures as callbacks. In this case, UserListScreen
can pass a closure to AddUserScreen
, which AddUserScreen
will call after collecting and validating the necessary information. This allows the new data to be passed back to UserListScreen
through the closure.
Here’s how the implementation of AddUserScreen
looks:
struct AddUserScreen: View {
@Environment(\.dismiss) private var dismiss
@State private var name: String = ""
let onUserAdd: (User) -> Void
var body: some View {
Form {
TextField("Name", text: $name)
Button("Save") {
let user = User(name: name)
onUserAdd(user)
dismiss()
}
}
}
}
The AddUserScreen
requires a mandatory onUserAdd
closure, which gets triggered when the save button is tapped. This closure allows the newly created user to be passed back to UserListScreen
.
Below is the implementation of the calling screen, UserListScreen
:
struct UserListScreen: View {
@Environment(\.httpClient) private var httpClient
@State private var users: [User] = []
@State private var isPresented: Bool = false
private func handleUserAdd(user: User) {
users.append(user)
}
var body: some View {
List(users) { user in
Text(user.name)
}
.sheet(isPresented: $isPresented, content: {
AddUserScreen(onUserAdd: handleUserAdd)
})
.task {
// fetch the users
}
.toolbar(content: {
ToolbarItem(placement: .topBarTrailing) {
Button("Add New User") {
isPresented = true
}
}
})
.navigationTitle("Users")
}
}
There are a couple of interesting points to highlight in UserListScreen
. First, we access the HTTPClient
directly within the view using custom @Environment
values. Additionally, UserListScreen
maintains a private state for the users
array, which allows it to manage user data locally.
Choosing to maintain private state for model objects within a view depends on the specific use case. Local state is ideal when the data is only relevant to that view and does not need to be shared across other parts of the application.
The handleUserAdd
method, defined inside UserListScreen
, is responsible for updating the users
array by appending the new user to the list.
Another technique is to pass @Binding
from UserListScreen
to AddUserScreen
. This way AddUserScreen
directly modify the users array, without needing a callback function.
The implementation is shown below:
struct AddUserScreen: View {
@Environment(\.dismiss) private var dismiss
@State private var name: String = ""
@Binding var users: [User]
var body: some View {
Form {
TextField("Name", text: $name)
Button("Save") {
let user = User(name: name)
users.append(user)
dismiss()
}
}
}
}
The SwiftUI @Binding
property wrapper allows a child view to communicate changes back to its parent view. Although a model might not technically be considered a child view, @Binding
still enables it to modify data passed from the parent, in this case, the UserListScreen
. This creates a two-way data flow, where changes made in the child view can directly update the state in the parent view.
Next, inside the UserListScreen
we can pass the binding as shown below:
var body: some View {
List(users) { user in
Text(user.name)
}
.sheet(isPresented: $isPresented, content: {
AddUserScreen(users: $users)
})
While this approach works, it feels inefficient to pass the entire array to another view just to add a single item. A more elegant solution is to use @Environment
, which allows us to manage shared state more cleanly across multiple views. We’ll explore this alternative approach next.
The @Environment
object provides a way to share state across different views in your application. It allows you to inject global state into any view that needs it, without making it a singleton. The key distinction is that @Environment
objects are dependent on where they are injected, meaning their scope is determined by the view hierarchy. Additionally, you can have multiple @Environment
objects throughout your app, each managing different aspects of your application’s state.
To address our needs, we can create an @Environment
object called UserStore
that manages user-related state and functionality. This UserStore
will handle tasks such as adding, updating, and deleting users, as well as fetching, sorting, and searching through user data. This centralized state management simplifies interactions with user data across the app.
The implementation is shown below:
@Observable
class UserStore {
// make HTTPClient protocol if needed
let httpClient: HTTPClient
private(set) var users: [User] = []
init(httpClient: HTTPClient) {
self.httpClient = httpClient
self.users = users
}
func addUser(_ user: User) {
users.append(user)
}
func loadUsers() async throws {
do {
users = try await httpClient.fetchUsers()
} catch {
print(error)
}
}
}
The UserStore
is marked as an ObservableObject
and depends on an HTTPClient
to handle network operations. It maintains an array of users, managing all the user-related state and functionality.
The UserListScreen
can access UserStore
via the environment. To enable this, the UserStore
needs to be injected into the environment, as shown below:
#Preview {
NavigationStack {
UserListScreen()
}
.environment(UserStore(httpClient: HTTPClient()))
}
Next, UserListScreen
can be updated to use UserStore
as shown below:
struct UserListScreen: View {
@Environment(UserStore.self) private var userStore
@State private var isPresented: Bool = false
var body: some View {
List(userStore.users) { user in
Text(user.name)
}
.sheet(isPresented: $isPresented, content: {
AddUserScreen()
})
.task {
do {
try await userStore.loadUsers()
} catch {
print(error)
}
}
.navigationTitle("Users")
}
}
We have removed the local state from the view and replaced it with @Environment
, allowing UserStore
to manage the state. Now that UserStore
is accessible through @Environment
, AddUserScreen
can directly interact with it to make changes. Here’s how that works:
struct AddUserScreen: View {
@Environment(\.dismiss) private var dismiss
@State private var name: String = ""
@Environment(UserStore.self) private var userStore
var body: some View {
Form {
TextField("Name", text: $name)
Button("Save") {
let user = User(name: name)
userStore.addUser(user)
dismiss()
}
}
}
}
Using this technique we did not have to pass any closure or binding. AddUserScreen
access the store through the environment and adds a new user.
One of the main benefits of this approach is that all the views that are interested in users array will automatically get updated. This means you don’t have to fight SwiftUI to manually cause a refresh.
If you’re relying on custom boolean flags to trigger view refreshes in SwiftUI, it likely indicates you’re not leveraging SwiftUI’s declarative nature correctly.
In this article, we explored various communication patterns in SwiftUI that enable seamless interaction between views. From using closures as callbacks, to leveraging @Binding
for direct data manipulation, and finally utilizing the power of @Environment
objects for shared state management, each method has its own strengths and use cases.
Closures offer simplicity and control, while @Binding
enables a two-way data flow. However, @Environment
objects provide a more scalable and clean solution for managing state globally, especially when multiple views need access to the same data. By using the right approach for the given scenario, you can avoid common pitfalls like relying on manual refresh mechanisms and instead let SwiftUI’s declarative design do the heavy lifting.
Choosing the correct communication pattern will lead to more maintainable, efficient, and readable SwiftUI applications.