A lightweight Elm-like Store for SwiftUI
ObservableStore
A simple Elm-like Store for SwiftUI, based on ObservableObject.
Like Elm or Redux, ObservableStore.Store
offers reliable unidirectional state and effects management. 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.
Because Store
is an ObservableObject, it can be used anywhere in SwiftUI that ObservableObject would be used.
Store is meant to be used as part of a single app-wide, or major-view-wide component. It deliberately does not solve for nested components or nested stores. Following Elm, deeply nested components are avoided. Instead, it is designed for apps that use a single store, or perhaps one store per major view. Instead of decomposing an app into many stateful components, ObservableStore favors decomposing an app into many stateless views that share the same store and actions. Sub-views can be passed data through bare properties of store.state
, or bindings, which can be created with store.binding
, or share the store globally, through EnvironmentObject
. See https://guide.elm-lang.org/architecture/ and https://guide.elm-lang.org/webapps/structure.html for more about this philosophy.
Example
A minimal example of Store used to increment a count with a button.
import SwiftUI
import os
import Combine
import ObservableStore
/// Actions
enum AppAction {
case increment
}
/// Services like API methods go here
struct AppEnvironment {
}
/// App state
struct AppState: Equatable {
var count = 0
/// State update function
static func update(
state: AppState,
environment: AppEnvironment,
action: AppAction
) -> Update<AppState, AppAction> {
switch action {
case .increment:
var model = state
model.count = model.count + 1
return Update(state: model)
}
}
}
struct AppView: View {
@StateObject var store = Store(
update: AppState.update,
state: AppState(),
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(action: .increment)
},
label: {
Text("Increment")
}
)
}
}
}
Store, state, updates, and actions
A Store
is a source of truth for a state. It’s an ObservableObject
. You can use it in a view via @ObservedObject
or @StateObject
to power view rendering.
Store exposes a single @Published
property, state
, which represents your application state. All updates and effects to this state happen through actions sent to store.send
.
state
is read-only, and cannot be updated directly. Instead, like Elm, or Redux, all state
changes happen through a single update
function, with the signature:
(State, Environment, Action) -> Update<State, Action>
The Update
returned is a small struct that contains a new state, plus any effects this state change should generate (more about that in a bit).
state
is modeled as an Equatable
type, typically a struct. Updates only mutate the state
property on store
when they are not equal. This means returning the same state twice is a no-op, and SwiftUI view body recalculations are only triggered if the state actually changes. Since state
is Equatable
, you can also make Store
-based views EquatableViews, wherever appropriate.
Effects
Updates are also able to produce asyncronous effects via Combine publishers. This lets you schedule asyncronous things like HTTP requests, or database calls, in response to actions. Using effects, you can model everything via a deterministic sequence of actions, even asyncronous side-effects.
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 asyncronous 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> {
// ...
}
}
The update function can pass this effect through Update(state:fx:)
func update(
state: State,
environment: Environment,
action: Action
) -> Update<State, Action> {
switch action {
// ...
case .authenticate(let credentials):
return Update(
state: state,
fx: environment.authenticate(credentials: credentials)
)
}
}
Store will manage the lifecycle of any publishers passed through fx
this way, piping the actions they produce back into the store, producing new states.