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 possible TabView states
  • ..added a new ContentView selectedTab property, controlling the TabView 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!

⭑⭑⭑⭑⭑

Further Reading

Explore SwiftUI

Browse all