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
.
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")
}
)
}
}
}
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.
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.
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.
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))
}
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.
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:
get
a local state from the root stateset
a local state on a root statetag
a local action so it becomes a root actionTogether, 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.
link |
Stars: 28 |
Last commit: 1 week ago |
actions
publisher by @gordonbrander in https://github.com/subconsciousnetwork/ObservableStore/pull/22Full Changelog: https://github.com/subconsciousnetwork/ObservableStore/compare/0.2.0...v0.3.0
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
)
)
}
}
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.
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