App-wide state in SwiftUI
With the adoption of SwiftUI, a new trend of creating an app-wide state has been gaining momentum. This is very justified, as it's one of the few ways to properly handle deep linking, HUDs, and more.
In this article, let's have a look at this approach, and how to avoid one of its most common pitfalls.
The app
We will build a small app with just two tabs: a home and settings tab.
While we will use a
TabView
as an example, the same approach can be used for navigation, sheets, all other SwiftUI view presentations, and much more.
struct ContentView: View {
var body: some View {
TabView {
HomeView()
.tabItem { Label("Home", systemImage: "house.fill") }
SettingsView()
.tabItem { Label("Settings", systemImage: "gear") }
}
}
}
struct HomeView: View {
var body: some View {
Text("Home")
}
}
struct SettingsView: View {
var body: some View {
Text("Settings")
}
}
Here we have declared a TabView
with our two views, HomeView
and SettingsView
, each with an associated Text
.
Controlling the selected tab
Our app has the requirement to select the active tab programmatically: for example we'd like to have a shortcut from Home allowing the user to jump into the settings tab, or perhaps we'd like to switch tabs as a response of a deep link, etc.
We will need to manage the TabView
state ourselves, which we can do via the TabView
initializer offering a Binding
parameter (we covered this and more in SwiftUI patterns: @Bindings):
enum Tab: Hashable {
case home
case settings
}
struct ContentView: View {
@State private var selectedTab: Tab = .home
var body: some View {
TabView(selection: $selectedTab) {
HomeView(selectedTab: $selectedTab)
.tabItem { Label("Home", systemImage: "house.fill") }
.tag(Tab.home)
SettingsView()
.tabItem { Label("Settings", systemImage: "gear") }
.tag(Tab.settings)
}
}
}
struct HomeView: View {
@Binding var selectedTab: Tab
var body: some View {
VStack {
Button("go to settings") {
selectedTab = .settings
}
Text("Home")
.onAppear(perform: { print("home on appear")})
}
}
}
struct SettingsView: View {
... // same as before
}
In this update we have..
- ..declared a
Tab
enum with all possibleTabView
states - ..added a new
ContentView
selectedTab
property, controlling theTabView
state - ..added a button in Home that will let us jump to the settings tab
While this works great and it lets us change the tab state programmatically, we still need to have direct access to ContentView
's selectedTab
property before we can change the tab bar state.
More importantly, as the app grows, it won't be practical to pass the tab bar state from view to view: this is where we move the state out of any view, and put it in the environment via our app-wide state.
App-wide state
The current limitation is the TabView
state accessibility, we can overcome this challenge by creating a global state and set it in the environment:
class AppWideState: ObservableObject {
@Published var selectedTab: Tab = .home
}
AppWideState
holds just the tab state and sends a new publish event every time selectedTab
is about to change.
We want this state to be accessible from anywhere, we will attach it to our main App
(or scene delegate) and then inject it in the environment:
@main
struct FiveStarsApp: App {
@StateObject var appWideState = AppWideState()
var body: some Scene {
WindowGroup {
ContentView()
.environmentObject(appWideState) // injected in the environment
}
}
}
Everything is ready, let's update our views to take advantage of this new state:
struct ContentView: View {
@EnvironmentObject var state: AppWideState // environment object
var body: some View {
TabView(selection: $state.selectedTab) { // state from the environment object
HomeView()
.tabItem { Label("Home", systemImage: "house.fill") }
.tag(Tab.home)
SettingsView()
.tabItem { Label("Settings", systemImage: "gear") }
.tag(Tab.settings)
}
}
}
struct HomeView: View {
@EnvironmentObject var state: AppWideState // environment object
var body: some View {
VStack {
Button("go to settings") {
state.selectedTab = .settings // sets the state from the environment object
}
Text("Home")
}
}
}
struct SettingsView: View {
... // same as before
}
HomeView
could have still used a@Binding
, however imagine that this view was a few levels deep.
This new structure works exactly as before, however we can now change the selected tab from the main App
and anywhere else the environment reaches:
we're very happy with this solution and we move on to the build other features.
A few weeks later (a.k.a. The pitfall)
A few weeks pass, our AppWideState
has gained some new @Published
properties, more views observe this object...and our app starts to slow down: every time we change the app-wide state we notice a slight lag before changing tabs or a new navigation is pushed, etc.
What's happening?
We can investigate this mystery with just our example. Let's add a small side effect in our HomeView
, a print
statement telling us when the HomeView
body is executed:
struct HomeView: View {
@EnvironmentObject var state: AppWideState
var body: some View {
let _ = print("HomeView body") // side effect
VStack {
Button("go to settings") {
state.selectedTab = .settings
}
Text("Home")
}
}
}
"Coincidentally" we covered this kind of side effects in Quick tips on embracing @ViewBuilder, alternatively we can use breakpoints as well.
With this small change, we can run the app again and we will notice that every time we change tabs (by tapping the tab bar or via the go to settings
button) the HomeView
body
is recomputed: this is true despite HomeView
not actually reading the state but just setting it.
If we remove the button action, thus not doing anything at all with the @EnvironmentObject
state, this will still happen:
struct HomeView: View {
@EnvironmentObject var state: AppWideState
var body: some View {
let _ = print("HomeView body") // side effect
VStack {
Button("go to settings") {
// does nothing
}
Text("Home")
}
}
}
Let's now imagine that:
- we have a navigation stack (or multiple navigation stacks)
- some of the screens in the stack(s) use our
AppWideState
environment object - the user is multiple levels deep into the stack(s)
Every change we make to AppWideState
will trigger a new body
evaluation for all views observing AppWideState
that are part of our stack(s), not just the last view the user is currently viewing.
It's now easy to tell why the app is getting slower and slower: the more we use and expand AppWideState
, the more views will have their body
re-evaluated at every change.
This pitfall is actually to be expected, as EnvironmentObject
is just another ObservableObject
instance that our views subscribe to, SwiftUI is doing just what it has always promised to be doing: automatically subscribing and reacting to state changes.
The solution
While most views will probably need to reach for AppWideState
to set it, very few will actually need to observe its state: taking the tab bar state as an example, only TabView
needs to observe it, all other views only need to change it.
A way to make this happen is to create a container of app-wide states, where the container itself doesn't publish anything:
class AppStateContainer: ObservableObject {
...
}
Instead of declaring each state as a @Published
property of this container, each state will be nested into its own ObservableObject
, which then will be part of the container:
class TabViewState: ObservableObject {
@Published var selectedTab: Tab = .home
}
class AppStateContainer: ObservableObject {
var tabViewState = TabViewState()
}
The container conforms to ObservableObject
because it's a requirement for environment objects, however it doesn't publish anything, meanwhile we moved the selectedTab
state to its own TabViewState
.
This approach also isolates each app-wise state into its own "mini-container" (TabViewState
above), therefore we can focus all the logic (if any) for each specific state to its own class, instead of having it all shared in one big class.
With this in place, we now can set both AppStateContainer
and TabViewState
into the environment:
@main
struct FiveStarsApp: App {
@StateObject var appStateContainer = AppStateContainer()
var body: some Scene {
WindowGroup {
ContentView()
.environmentObject(appStateContainer)
.environmentObject(appStateContainer.tabViewState)
}
}
}
Views that need to observe the state change will directly observe TabViewState
, while views that only need to change its state will reach for the container:
struct ContentView: View {
@EnvironmentObject var state: TabViewState
var body: some View {
TabView(selection: $state.selectedTab) {
HomeView()
.tabItem {
Label("Home", systemImage: "house.fill")
}
.tag(Tab.home)
SettingsView()
.tabItem {
Label("Settings", systemImage: "gear")
}
.tag(Tab.settings)
}
}
}
struct HomeView: View {
@EnvironmentObject var container: AppStateContainer
var body: some View {
let _ = print("Home body")
VStack {
Button("go to settings") {
container.tabViewState.selectedTab = .settings
}
Text("Home")
.onAppear(perform: { print("home on appear")})
}
}
}
And with this change we have our app performance back, no extra view body
unnecessarily computed.
In this example we have HomeView
directly reach for and change the TabView
state itself, however we could also add some convenience API on top of our container to make this more straightforward (similarly to what we did in Custom HUDs in SwiftUI).
The final gist can be found here.
Conclusions
Along with SwiftUI we have new paradigms, while it's very good to explore and experiment with new architectures, we always need to be aware of "hidden" pitfalls that we might encounter.
Do you use a "global" state in your apps? What approach do you use? Please let me know!
Thank you for reading and stay tuned for more articles!