Simple State Management in SwiftUI cover image

Simple State Management in SwiftUI

Kane Cohen • October 15, 2020

swiftui

Web and particularly the JS-heavy frontend part of the web field has been around for years now. Three biggest players there: React, Angular and Vue had better half of a decade to foster community and ecosystem around themselves which gave birth to a number of interesting approaches to the problem of state management. Arguably, most popular solutions to that problem are: Redux-like which builds upon Flux pattern and MobX-like which brings forward Observable pattern.

I'm not going to go into details of each library and pattern - that's not the scope of this post. What I will concentrate here is a possible implementation of an Observable pattern to be used in SwiftUI applications. What is this Observable pattern? Well, in essence it is an instance of an Object or Entity in your application which includes a number of properties that store state data. All of these properties are being Observed for changes and when changes to the state do happen it triggers refresh/rerender of various views in the application. That's the gist of it - there is a lot of implementations of this pattern, but they all have the same core idea - State object is being watched for changes which refreshes the app. Then there's a matter of how an application can change those state properties - that is usually done via "actions" or methods that mutate the state.

I'd recommend checking google for more in-depth explanation of Observable and Flux patterns if you're interested. Continuing, I'll go over a fairly simple code sample which displays how Observable pattern state management could be implemented in SwiftUI applications.

In this simple example I'll keep everything in two files: AppStore.swift and AppView.swift.

First, let's look at the AppStore.swift file which includes class that will manage application state:

import Foundation
import SwiftUI
import Combine

// Struct that represents contents of the app state.
struct AppState {
    var todos: [Todo]
    var allowNotifications: Bool = false
    var activeTab: AppView.Tab = .todos
    var activeTodo: UUID? = nil
}

// Singleton class that creates instance of itself which in turn creates an instance of AppState.
final class AppStore: ObservableObject {
    static let shared: AppStore = AppStore()

    // When AppStore instance created in a line above, it also creates a instance of AppState
    // struct which contains initial **default** state of the application.
    @Published var state = AppState(todos: [])

    override init() {
        super.init()

        // Execute methods to restore navigation state from UserDefaults and request access
        // to send notifications to the user.
        restoreNav()
        requestNotificationAccess()
    }

    // Get data from built-in app store - UserDefaults. Should be used for simple bits of
    // data: integers, strings and such. Don't store complex data here.
    func restoreNav() {
        let tab = Int16(UserDefaults.standard.integer(forKey: "activetab"))
        state.activeTab = AppView.Tab(rawValue: tab)!
    }

    // Perform built-in iOS notification permissions request and update state showing whether
    // user agreed to receive app notifications.
    func requestNotificationAccess() {
        UNUserNotificationCenter
            .current()
            .requestAuthorization(options: [.alert, .badge, .sound]) { success, error in
                if success {
                    self.state.allowNotifications = true
                }
            }
    }

    // This method should be called externally when application is being closed. All it does
    // is just stores current activeTab state for later.
    func exit() {
        UserDefaults.standard.set(state.activeTab.rawValue, forKey: "activetab")
    }

    // In this simple application state store we keep this method to add additional "todo"
    // items. For larger applications it might be better to split App Store into smaller
    // separate "stores" for different application entities: AppStore, TodoStore, ... .
    func addTodo() {
        let todo = Todo(
            id: UUID(),
            name: "New Item",
            status: false
        )

        state.todos.append(todo)
    }
}

Now, here's how application store could be used in a view (AppView.swift):

struct AppView: View {
    // Assign singleton instance that is stored in a "shared" static property of AppStore.
    // Marking it with @ObservedObject tells SwiftUI that we want to listen to changes
    // that happen to the AppState that is marked as @Published in AppStore.
    @ObservedObject private var store: AppStore = .shared

    // Notice, that using "$" at the beginning of the selection argument we specify
    // to SwiftUI that if TabView tab changes it should automatically update
    // "active" property of the AppState struct in the AppStore singleton.
    var body: some View {
        TabView(selection: $store.state.active) {
            TodoView()
                .tag(Tab.todos)
                .tabItem {
                    Text("Todos")
                }

            SettingsView()
                .tag(Tab.settings)
                .tabItem {
                    Text("Settings")
                }
        }
    }
}

// This extension to the view specifies which tabs are available.
extension AppView {
    enum Tab: Int16 {
        case todos, settings
    }
}

And that is all. With this fairly simple piece of singleton class it is possible to have MobX-like state management in a SwiftUI application. I'd like to note that any time you add @ObservedObject private var store: AppStore = .shared in a view - you're subjecting that view to a possible unnecessary rerender if state changes.

Talking about rerenders - it's a mark of a good, smooth-performing application that does not do unnecessary view rerenders, which is why splitting application state into multiple separate "stores" is recommended. That way when one part of the state mutation won't lead to a possible extra rerender.