In a single-store app, a view that reads Store<AppState, AppAction> directly drags the
whole app state into itself: formatting logic hides in the body, previews need a full store,
and the view cannot move to its own Swift Package. A Connector maps app state to a small view
state, and view actions back to app actions.
The Connector Protocol
Two pure functions: state in, view state out; view action in, app action out. connect(using:)
wraps the store's existing derived mechanism.
protocol Connector {
associatedtype State
associatedtype Action
associatedtype ViewState: Equatable
associatedtype ViewAction: Equatable
func connect(state: State) -> ViewState
func connect(action: ViewAction) -> Action
}
extension Store {
func connect<C: Connector>(
using connector: C
) -> Store<C.ViewState, C.ViewAction> where C.State == State, C.Action == Action {
derived(
deriveState: connector.connect(state:),
embedAction: connector.connect(action:)
)
}
}
View State Owned by the View
The view declares exactly the data it renders and the only actions it can send. Wrong-action bugs disappear, and the view no longer knows the app state exists.
extension HistoryView {
struct State: Equatable {
let posters: [Poster]
struct Poster: Hashable {
let ids: Ids
let imageURL: URL?
let title: String
let subtitle: String
}
}
enum Action: Equatable {
case markAsWatched(episode: Ids)
}
typealias ViewModel = Store<State, Action>
}
The body becomes dumb rendering plus viewModel.send(...). Previews stub the store with a
reducer that does nothing, so covering loading and empty states is cheap.
The Connector Does the Formatting
All lookup and formatting moves into a value type, which unit tests can call directly.
struct WatchedHistoryConnector: Connector {
func connect(state: AppState) -> HistoryView.State {
.init(posters: state.watchedHistory.compactMap { ids in
let episode = state.episodes[ids]
return HistoryView.State.Poster(
ids: ids,
imageURL: state.showImages[ids]?.tvPosters?.first?.url,
title: episode?.title ?? "",
subtitle: DateFormatter.shortDate.string(for: episode?.firstAired) ?? ""
)
})
}
func connect(action: HistoryView.Action) -> AppAction {
switch action {
case let .markAsWatched(episode):
return AppAction.markAsWatched(episode: episode, watched: true)
}
}
}
A container view binds the two: HistoryView(viewModel: store.connect(using: Connectors.WatchedHistoryConnector())). The pattern comes from Redux and The Composable
Architecture; part 4 of the series.