View Store is an architecture pattern and a protocol used in SwiftUI development inspired by The Composable Architecture. A view store is an ObservableObject that allows us to separate view-specific logic and the rendering of a corresponding view in a way that is repeatable, prescriptive, flexible, and testable by default. It is available as a Swift Package.
The protocol declaration itself is quite simple:
protocol ViewStore: ObservableObject {
associatedtype ViewState
associatedtype Action
var viewState: ViewState { get }
func send(_ action: Action)
}The viewState property on the ViewStore protocol is the single source of truth for data that the corresponding View uses. It should always be declared as a @Published property. Similar to a view model, the properties on ViewState should not require the corresponding View to perform any additional transformation logic or formatting for display (e.g. text, numbers, and dates).
Actions can be performed by the corresponding View using the send(_ action:) API. Action is typically modeled as an enum. When an action is performed, it typically has an effect on the view state. For example, .refresh might be an action that a View triggers on a view store, resulting in it re-fetching data and updating its corresponding viewState.
A view store can combine many different sources of data into its single viewState property. Typically, this data is sourced from networking, persistence, or input from user actions (via the send(_ action:) API). The Combine framework is used to combine the data sources using combineLatest in a single stream that updates the viewState property when any of the data sources produce new values. This creates a single pipeline for all changes that is predictable, repeatable, and guarantees that we have the latest values from each data source. Because viewState is a @Published property and the ViewStore is an ObservableObject, all changes will cause the corresponding View to update.
To perform an action, we typically create a PassthroughSubject, which is then used in the Combine pipeline to update the viewState. We call the send(_ action:) API to perform the action, and then typically send a new value to the PassthroughSubject.
enum Action {
case toggleShowsPhotoCount(Bool)
}
private let showsPhotosCountPublisher = PassthroughSubject<Bool, Never>()
func send(_ action: Action) {
switch action {
case let .toggleShowsPhotoCount(showsPhotoCount):
showsPhotosCountPublisher.send(showsPhotoCount)
}
}Additionally, PassthroughSubjects are prepended with an initial value, since their usage in combineLatest requires that a value be emitted before the combineLatest can emit.
let showsPhotosCountPublisher = self.showsPhotosCountPublisher.prepend(ViewState.initial.showsPhotoCount)
photoPublisher
.combineLatest(showsPhotosCountPublisher)
.map { /* transformation to ViewState */ }
.assign(to: &$viewState)Many SwiftUI APIs accept bindings for state that is both read and written to. Binding properties or methods are frequently declared on view stores as a convenience for working with these APIs. To keep a single source of truth, the binding typically reads a property on the viewState and performs an action that results in an update to the view state.
var showsPhotoCount: Binding<Bool> {
return Binding<Bool> {
self.viewState.showsPhotoCount
} set: { newValue in
self.send(.toggleShowsPhotoCount(newValue))
}
}Rather than using send(_ action:) directly, the View would instead use this binding.
Toggle("Show Count", isOn: store.showsPhotoCount)As a convenience, a makeBinding API is provided in an extension of the ViewStore protocol to create a succinct syntax for this common case. This extension uses CasePaths.
var showsPhotoCount: Binding<Bool> {
makeBinding(viewStateKeyPath: \.showsPhotoCount, actionCasePath: /Action.toggleShowsPhotoCount)
}You often may consider exposing computed properties that perform additional transformations on existing State properties. When doing so, add these computed properties to State or an extension on the State type, rather than to the view store itself. This way, almost all state property access happens consistently through state at the call site. When using an extension, you may make the extension private, and declare it within the corresponding View’s file if no other clients need to access the computed properties.
For example, consider an extension declared privately within PhotoList.swift, which contains the Swift UI View that corresponds to PhotoListViewStore:
private extension PhotoListViewStore.State {
var hasPhotos: Bool {
switch status {
case .error, .loading:
return false
case let .content(photos):
return !photos.isEmpty
}
}
}An example usage of the View Store pattern can be found in the ViewStore package repository. In this project, PhotoList.swift makes use of PhotoListViewStore to perform network requests and format data for display in the list, as well as update the source of truth via actions (searching and a Toggle) performed by the user. PhotoListOriginal.swift, for the sake of comparison, does not use a view store.
Not every SwiftUI View will have a corresponding view store. Some views are simple enough to pass all information in on init without any added complexity. However, the data passed to these simple views should originate from a view store of a parent view. For example, when displaying a grid of photos fetched from the network, a view store could be used to fetch and transform network data into the viewState used to populate the grid. Each grid element, however, doesn’t require additional data transformation, so a grid element’s corresponding View need not have a view store.
The same can be done with minor actions on these simple views. The actions should be handled by a view store, but for simple views, that action can be performed by passing in a closure that the parent can specify.
struct PhotoGrid: View {
@StateObject private var store: PhotoListViewStore
init(provider: Provider) {
self._store = StateObject(wrappedValue: PhotoListViewStore(provider: provider))
}
var body: some View {
ScrollView {
LazyVGrid(columns: columns, alignment: .center, spacing: 10) {
ForEach(store.viewState.photos) { photo in
PhotoGridElement(thumbnailUrl: photo.thumbnailUrl) {
store.send(.tapPhoto(id: photo.id))
}
}
}
}
}
}
struct PhotoGridElement: View {
let thumbnailUrl: URL
let onTap: () -> Void
var body: some View {
AsyncImage(url: thumbnailUrl) { image in
image.resizable()
.aspectRatio(contentMode: .fit)
} placeholder: {
ProgressView()
}
.onTapGesture {
onTap()
}
}
}