Skip to content

subconsciousnetwork/ObservableStore

Repository files navigation

ObservableStore

A simple Elm-like Store for SwiftUI, based on ObservableObject.

ObservableStore helps you craft more reliable apps, by centralizing all of your application state into one place and giving you a deterministic system for managing state changes and side-effects. All state updates happen through actions passed to an update function. This guarantees your application will produce exactly the same state, given the same actions in the same order. If you’ve ever used Elm or Redux, you get the gist.

Because Store is an ObservableObject, it can be used anywhere in SwiftUI that ObservableObject would be used.

You can centralize all application state in a single Store, use the Store as an EnvironmentObject, or create multiple @StateObject stores. You can also pass scoped parts of a store down to sub-views as @Bindings, as scoped ViewStores, or as ordinary bare properties of store.state.

Example

A minimal example of Store used to increment a count with a button.

import SwiftUI
import Combine
import ObservableStore

/// Actions
enum AppAction {
    case increment
}

/// Services like API methods go here
struct AppEnvironment {
}

/// Conform your model to `ModelProtocol`.
/// A `ModelProtocol` is any `Equatable` that has a static update function
/// like the one below.
struct AppModel: ModelProtocol {
    var count = 0

    /// Update function
    static func update(
        state: AppModel,
        action: AppAction,
        environment: AppEnvironment
    ) -> Update<AppModel> {
        switch action {
        case .increment:
            var model = state
            model.count = model.count + 1
            return Update(state: model)
        }
    }
}

struct AppView: View {
    @StateObject var store = Store(
        state: AppModel(),
        environment: AppEnvironment()
    )

    var body: some View {
        VStack {
            Text("The count is: \(store.state.count)")
            Button(
                action: {
                    // Send `.increment` action to store,
                    // updating state.
                    store.send(.increment)
                },
                label: {
                    Text("Increment")
                }
            )
        }
    }
}

State, updates, and actions

A Store is a source of truth for application state. It's an ObservableObject, so you can use it anywhere in SwiftUI that you would use an ObservableObject—as an @ObservedObject, a @StateObject, or @EnvironmentObject.

Store exposes a single @Published property, state, which represents your application state. state can be any type that conforms to ModelProtocol.

state is read-only, and cannot be updated directly. Instead, all state changes are returned by an update function that you implement as part of ModelProtocol.

struct AppModel: ModelProtocol {
    var count = 0

    /// Update function
    static func update(
        state: AppModel,
        action: AppAction,
        environment: AppEnvironment
    ) -> Update<AppModel> {
        switch action {
        case .increment:
            var model = state
            model.count = model.count + 1
            return Update(state: model)
        }
    }
}

The Update returned is a small struct that contains a new state, plus any optional effects and animations associated with the state transition (more about that in a bit).

ModelProtocol inherits from Equatable. Before setting a new state, Store checks that it is not equal to the previous state. New states that are equal to old states are not set, making them a no-op. This means views only recalculate when the state actually changes.

Effects

Updates are also able to produce asynchronous effects via Combine publishers. This gives you a deterministic way to schedule sync and async side-effects, like HTTP requests or database calls in response to actions.

Effects are modeled as Combine Publishers, which publish actions and never fail. For convenience, ObservableStore defines a typealias for effect publishers:

public typealias Fx<Action> = AnyPublisher<Action, Never>

You can produce effects by exposing services or methods on Environment that produce Combine publishers.

Another common approach is to make the environment (or some of its services) actors. This has the advantage of getting work off the main thread.

actor Environment {
    // ...
    func authenticate(credentials: Credentials) async -> Action {
        // ...
    }
}

You can then wrap actor method calls in publishers. ObservableStore provides a helpful extension for this that allows you to construct a Combine Future from an async closure.

Here's an example of creating an effect using an environment actor and returning it as part of the update:

func update(
    state: Model,
    action: Action,
    environment: Environment
) -> Update<Model> {
    switch action {
    // ...
    case .authenticate(let credentials):
        let fx = Future {
            await environment.authenticate(credentials: credentials)
        }
        .eraseToAnyPublisher()
        
        return Update(state: state, fx: fx)
    }
}

Store will manage the lifecycle of any publishers returned by an Update; piping the actions they produce back into the store, producing new states, and cleaning them up when they complete.

Animations

You can also drive explicit animations as part of an Update.

Use Update.animation to set an explicit Animation for this state update.

func update(
    state: Model,
    action: Action,
    environment: Environment
) -> Update<Model> {
    switch action {
    // ...
    case .authenticate(let credentials):
        return Update(state: state).animation(.default)
    }
}

When you specify a transition or animation as part of an Update, Store will use that animation when setting the state for the update.

Getting and setting state in views

There are a few different ways to work with Store in views.

Store.state lets you reference the current state directly within views. It’s read-only, so this is the approach to take if your view just needs to read, and doesn’t need to change state.

Text(store.state.text)

Store.send(_) lets you send actions to the store to change state. You might call send within a button action or event callback, for example.

Button("Set color to red") {
    store.send(AppAction.setColor(.red))
}

Bindings

StoreProtocol.binding(get:tag:) lets you create a binding that represents some part of a store state. The get closure reads the state into a value, and the tag closure wraps the value set on the binding in an action. The result is a binding that can be passed to any vanilla SwiftUI view, changing state only through deterministic updates.

TextField(
    "Username"
    text: store.binding(
        get: { state in state.username },
        tag: { username in .setUsername(username) }
    )
)

Bottom line, because Store is just an ordinary ObservableObject and can produce bindings, you can write views exactly the same way you write vanilla SwiftUI views. No special magic! Properties, @Binding, @ObservedObject, @StateObject and @EnvironmentObject all work as you would expect.

Creating scoped child components

We can also create ViewStores that represent just a scoped part of the root store. You can think of them as being like a binding, but they expose a StoreProtocol interface, instead of a binding interface. This allows you to create apps from free-standing components that all have their own local state, actions, and update functions, but share the same underlying root store.

Imagine we have a SWiftUI child view that looks something like this:

enum ChildAction {
    case increment
}

struct ChildModel: ModelProtocol {
    var count: Int = 0

    static func update(
        state: ChildModel,
        action: ChildAction,
        environment: Void
    ) -> Update<ChildModel> {
        switch action {
        case .increment:
            var model = state
            model.count = model.count + 1
            return Update(state: model)
        }
    }
}

struct ChildView: View {
    var store: ViewStore<ChildModel>

    var body: some View {
        VStack {
            Text("Count \(store.state.count)")
            Button(
                "Increment",
                action: {
                    store.send(ChildAction.increment)
                }
            )
        }
    }
}

To integrate this child component with a parent component, we're going to need 3 functions:

  • A function to get a local state from the root state
  • A function to set a local state on a root state
  • A function to tag a local action so it becomes a root action

Together, these functions give us everything we need to map from child domain to a parent domain. Let's define them as static functions, so we have them all in one place.

struct AppChildCursor {
    /// Get child state from parent
    static func get(_ state: ParentModel) -> ChildModel {
        state.child
    }

    /// Set child state on parent
    static func set(_ state: ParentModel, _ child: ChildModel) -> ParentModel {
        var model = state
        model.child = child
        return model
    }

    /// Tag child action so it becomes a parent action
    static func tag(_ action: ChildAction) -> ParentAction {
        switch action {
        default:
            return .child(action)
        }
    }
}

Ok, now that we have everything we need to map from the parent domain to the child domain, let's integrate the child view with the parent view.

We call the store.viewStore(get:tag:) method to create a scoped ViewStore from our store and pass it the appropriate cursor functions.

struct ContentView: View {
    @StateObject private var store: Store<AppModel>

    var body: some View {
        ChildView(
            store: store.viewStore(
                get: AppChildCursor.get,
                tag: AppChildCursor.tag
            )
        )
    }
}

Note that .viewStore(get:tag:) is an extension of StoreProtocol, so you can call it on Store or ViewStore to create arbitrarily nested components!

Next, we want to integrate the child's update function into the parent update function. Luckily, ModelProtocol synthesizes an update(get:set:tag:state:action:environment) function that automatically maps child state and actions to parent state and actions.

enum AppAction {
    case child(ChildAction)
}

struct AppModel: ModelProtocol {
    var child = ChildModel()

    static func update(
        state: AppModel,
        action: AppAction,
        environment: AppEnvironment
    ) -> Update<AppModel> {
        switch {
        case .child(let action):
            return update(
                get: AppChildCursor.get,
                set: AppChildCursor.set,
                tag: AppChildCursor.tag,
                state: state,
                action: action,
                environment: ()
            )
        }
    }
}

And that's it! We have successfully created an isolated child component and integrated it into a parent component. This tagging/update pattern also gives parent components an opportunity to intercept and handle child actions in special ways.