Swiftpack.co - subconsciousnetwork/ObservableStore as Swift Package

Swiftpack.co is a collection of thousands of indexed Swift packages. Search packages.
See all packages published by subconsciousnetwork.
subconsciousnetwork/ObservableStore v0.3.0
A lightweight Elm-like Store for SwiftUI
⭐️ 28
🕓 1 week ago
iOS macOS
.package(url: "https://github.com/subconsciousnetwork/ObservableStore.git", from: "v0.3.0")

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, and 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 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>

The most common way to produce effects is by exposing methods on Environment that produce effects publishers. For example, an asynchronous call to an authentication API service might be implemented in Environment, where an effects publisher is used to signal whether authentication was successful.

struct Environment {
    // ...
    func authenticate(credentials: Credentials) -> AnyPublisher<Action, Never> {
      // ...
    }
}

You can subscribe to an effects publisher by returning it as part of an Update:

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

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

Binding(get:send:tag:) lets you create a binding that represents some part of the 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, but changes state only through deterministic updates.

TextField(
    "Username"
    text: Binding(
        get: { store.state.username },
        send: store.send,
        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 component-scoped state and send callbacks from a shared root store. 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 vanilla 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 state: ChildModel
    var send: (ChildAction) -> Void

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

Let's integrate this child component with a parent component. This is where CursorProtocol comes in. It defines three things:

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

Together, these functions give us everything we need to map from child to parent.

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

    /// Set child state on parent
    static func set(state: ParentModel, inner 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)
        }
    }
}

Let's start by integrating the child view with the parent view. We pass down part of the parent state to the child, along with a scoped send callback that will map child actions to parent actions. We can create this scoped send with Address.forward, passing it the store's send method, and the cursor's tag function.

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

    var body: some View {
        ChildView(
            state: store.state.child,
            send: Address.forward(
                send: store.send,
                tag: AppChildCursor.tag
            )
        )
    }
}

Next, we want to integrate the child's update function into the parent update function. Luckily, CursorProtocol synthesizes an update 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 AppChildCursor.update(
                state: state,
                action: action,
                environment: ()
            )
        }
    }
}

This tagging/update pattern also gives parent components an opportunity to intercept and handle child actions in special ways.

GitHub

link
Stars: 28
Last commit: 1 week ago
jonrohan Something's broken? Yell at me @ptrpavlik. Praise and feedback (and money) is also welcome.

Release Notes

v0.3.0
1 week ago

What's Changed

Full Changelog: https://github.com/subconsciousnetwork/ObservableStore/compare/0.2.0...v0.3.0

Breaking: removed ViewStore in favor of forward

ViewStore has been removed in favor of a new child component patter that is closer to vanilla SwiftUI.

The key problem with the ViewStore approach was that the getter for the store value dynamically got the value, meaning that for stores of list items, the state of the store always had to be an Optional. SwiftUI avoids this problem by passing down bare properties to list items. See #19 for more details.

Child components are now handled the way you handle child components in vanilla SwiftUI, with bare state and a send method.

struct ChildView: View {
    var state: ChildModel
    var send: (ChildAction) -> Void

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

To integrate the child view, we can use Address.forward to create a tagged send function for the child that will translate all child actions into parent actions.

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

    var body: some View {
        ChildView(
            state: store.state.child,
            send: Address.forward(
                send: store.send,
                tag: AppChildCursor.tag
            )
        )
    }
}

Breaking: Binding signature change

Bindings have changed from Binding(store:get:tag) to Binding(get:send:tag:). Typical use looks like this:

TextView(
    "Placeholder",
    Binding(
        get: { store.state.text },
        send: store.send,
        tag: Action.setText
    )
)

The old signature relied on ViewStore's conformance to StoreProtocol. The new signature just requires a getter closure and a send function, so is less opinionated about the specifics of this library.

Actions publisher

Store now exposes Store.actions, a publisher which publishes all actions sent to send. This is useful for logging actions. It is also useful for integrating ObservableStore with other SwiftUI features, or for wiring together stores in codebases that use multiple stores.

Now both state and actions are published (state already has a publisher through $state), so it is easy to wired up store events to other SwiftUI features using onReceive.

Logging example:

.onReceive(store.actions) { action in
    // do logging
}

Swiftpack is being maintained by Petr Pavlik | @ptrpavlik | @swiftpackco | API | Analytics