I don’t know if you’re building an iOS or macOS app, but if you are, here’s something that might be useful to you. I’m open sourcing a Swift library that I’ve been using to build Subconscious.
TLDR: A lightweight Elm-like store for SwiftUI.
GitHub: github.com/gordonbrander/ObservableStore
License: Apache-2.0
ObservableStore helps you craft more reliable apps by centralizing all of your application state into one place, and making all changes to state deterministic. If you’ve ever used Redux or Elm, you get the gist.
Really, it’s just the thinnest amount of icing on top of SwiftUI to enable an Elm-like app architecture. Less than 200 LOC, including comments.
What’s so great about the Elm App Architecture pattern?
Most user interface bugs are state-related. Not a surprise when you consider that UI is one big concurrency problem. User interaction, animation, and asynchronous processes such as http requests, all happen constantly within a UI, returning who-knows-when, interleaving with each other, mutating state. It’s terribly easy for these processes to step on each other’s toes and produce a broken app states.
Elm App Architecture is a general pattern designed to solve this problem. The idea is to centralize all of your application state into one place—the store. You can’t mutate this state directly. Instead, any changes to app state are done through messages sent to this central store.
Here’s how this works in practice. An app is made up of three things:
State — describes all state in your app. In our case, this is a struct.
Update — a function that, given a state and a message, knows how to produce a new state.
View — a way to transform a state into UI. In our case, an ordinary SwiftUI View.
Anything that might change app state—user interactions, events, API calls—is passed to update as a message (called an “action”). Then update
decides if and how these actions should change the state. This turns your app into one big state machine. Your app will produce exactly the same state for the same messages in the same order.
Because all changes happen through a single update function, you can log every action that passes through the app, and all of the states this produces. You can see everything that happens, and in what order.
This makes managing complex state tractable. Fewer bugs get written. When they happen they are easier to troubleshoot and fix. Testing is easy, too. You can even record and replay actions to do UI and integration testing.
How to use it
Here’s a quick example of using ObservableStore to build a SwiftUI app that increments a count when you press a button.
import SwiftUI
import Combine
import ObservableStore
/// AppAction is an enum that represents all of the
/// possible messages that can be sent to update.
enum AppAction {
case increment
}
/// Environment is a place to put things like API methods.
struct AppEnvironment {}
/// AppState models all of our app's state.
struct AppState: Equatable {
var count = 0
/// Here's our update function.
///
/// It takes:
/// - the previous state
/// - the environment
/// - an action
///
/// Notice how we use `switch` to cover all
/// possible cases that could change app state.
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 {
/// Initialize Store with:
/// - update function
/// - initial state
/// - environment
@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")
}
)
}
}
}
How it all works
Store is an ObservableObject
with a single @Published
property, called state
. When a new state is produced as the result of an action, it is set on Store.state
, firing objectWillChange, and triggering a SwiftUI view recalculation.
You can use Store
anywhere in SwiftUI that you would use an ObservableObject
—as an @ObservedObject
, a @StateObject
, or @EnvironmentObject
.
state
is 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 something actually changes.
Also, because state
is Equatable
, you can make any view that relies on it, or part of it, an EquatableView, so the view’s body will only be recalculated if the values it cares about change.
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.
Store.send(action:)
lets you send actions to the store to change state
. You might call send within a button action, or event callback, for example.
Store.binding(get:tag:animation:)
lets you create a binding that represents some value in the state, and sends actions back to the store when you mutate the binding. Many APIs in SwiftUI use bindings, so this is a pretty useful method.
Creating a binding from the store is easy. A get
function reads the state into a value, a tag
function turns a value set on the binding into an action. The result is a binding that can be passed to any vanilla SwiftUI view, yet changes state only through deterministic updates.
TextField(
"Username"
text: store.binding(
get: { state in state.username },
tag: { username in .setUsername(username) }
)
)
Effects
Note that the update function in our example returns a new state wrapped in an Update
, rather than just returning that state directly. This is because Update(state:fx:)
is a struct that holds two things: a state, and any asynchronous effects.
Asynchronous effects management is one of the big missing features of SwiftUI. Effects in ObservableStore give you a way to schedule asynchronous processes—HTTP requests, APIs, database calls—deterministically, in response to state
changes. I make extensive use of effects in Subconscious to do all my database communication off-main-thread, so it can’t block UI.
Effects are just Combine publishers that publish actions, allowing you to leverage the full power of Apple’s asynchronous streams framework. To schedule effects, just pass them along with the Update
:
Update(
state: next,
fx: Just(.increment).eraseToAnyPublisher()
)
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 adding methods to Environment
that produce publishers. For example, an asynchronous call to an authentication API might be implemented as a method of Environment
that returns an effects publisher which signals when authentication succeeds or fails.
struct Environment {
func authenticate(
credentials: Credentials
) -> AnyPublisher<Action, Never> {
// ...
}
}
The update function can schedule this effect by returning it 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
, piping the actions they produce back into the store, producing new states
.
How do I add it to my Xcode project?
Go to File > Add Packages
Then paste in: https://github.com/gordonbrander/ObservableStore
.
Contributing
Hope this little library is helpful. I know it’s been crucial for me as I develop Subconscious. Contributions are welcome!
GitHub: github.com/gordonbrander/ObservableStore
License: Apache-2.0