Redux-like state container in SwiftUI: Connectors.
Sergey Leschev
Posted on March 18, 2023
In the past month, I gained a deep appreciation for the benefits of having a single source of truth and a state container to manage the entire application's state in a single place. I've already implemented this strategy in a few of my previous apps and intend to continue employing it in all future projects.
- Basics
- Reducer and Actions
- Unidirectional flow
- Side effects
- Usage
- State normalization
- State composition
- Reducer composition
- Derived stores
- Container Views
import Foundation
struct AppState: Equatable {
var showsById: [Ids: Show] = [:]
var seasons: [Ids: Season] = [:]
var episodes: [Ids: Episode] = [:]
var showImages: [Ids: FanartImages] = [:]
var watchedHistory: [Ids] = []
}
enum AppAction: Equatable {
case markAsWatched(episode: Ids, watched: Bool)
}
import SwiftUI
import KingfisherSwiftUI
struct HistoryView: View {
@ObservedObject var store: Store<AppState, AppAction>
var body: some View {
LazyVGrid(columns: [.init(), .init()]) {
ForEach(store.state.watchedHistory, id: \.self) { id in
posterView(for: id)
}
}
}
private func posterView(for id: Ids) -> some View {
let image = store.state.showImages[id]?.tvPosters?.first?.url
let episode = store.state.episodes[id]
let title = episode?.title ?? ""
let date = episode?.firstAired ?? Date()
return VStack {
image.map {
KFImage($0)
}
Text(title)
Text(verbatim: DateFormatter.shortDate.string(for: date))
.foregroundColor(.secondary)
.font(.subheadline)
}
}
}
The main issue here is the formatting logic that lives inside the view. We can’t verify it using unit tests. Another problem is SwiftUI previews. We have to provide the entire store with the whole app state to render a single screen. And we can’t keep the view in a separated Swift Package because it depends on the whole app state.
We can improve the case a little bit by using derived stores that provide only the app state’s needed part. But we still need to keep the formatting logic somewhere outside of the view.
Let me introduce another component that lives in between the whole app store and the dedicated view. The primary responsibility of this component is the transformation of the app state to the view state. I call it Connector, and it is Redux inspired component.
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: )
)
}
}
Connector
is a simple protocol that defines two functions. The first one transforms the whole app state into the view state, and the second one converts view actions into app actions. Let’s refactor our view by introducing view state and view actions.
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>
}
We create an entirely different model for our view that holds the only needed data. The view state here is a direct mapping of the view representation and its model. The view action enum is the only action that available for this particular view. You eliminate the accidents where you call unrelated actions. Finally, your view is fully independent, which allows you to extract it into a separated Swift Package.
import KingfisherSwiftUI
import SwiftUI
struct HistoryView: View {
@ObservedObject var viewModel: ViewModel
var body: some View {
LazyVGrid(columns: [.init(), .init()]) {
ForEach(viewModel.state.posters, id: \.title) { poster in
VStack {
poster.imageURL.map {
KFImage($0)
}
Text(poster.title)
Text(poster.subtitle)
}.onTapGesture {
viewModel.send(.markAsWatched(episode: poster.ids))
}
}
}
}
}
Another benefit here is the simple view. It doesn’t do anything. The view displays the formatted data and sends actions. You can quickly write as many SwiftUI previews as you need to cover all the different cases like loading, empty, etc.
extension Store {
static func stub(with state: State) -> Store {
Store(
initialState: state,
reducer: .init { _, _, _ in Empty().eraseToAnyPublisher() },
environment: ()
)
}
}
struct HistoryView_Previews: PreviewProvider {
static var previews: some View {
HistoryView(
viewModel: .stub(
with: .init(
posters: [
.init(
ids: Ids(trakt: 1),
imageURL: URL(
staticString: "https://domain.com/image.jpg"
),
title: "Film",
subtitle: "Science"
)
]
)
)
)
}
}
It’s time to create the particular connector type, which we will use to bind the app state to the view state.
enum Connectors {}
extension Connectors {
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)
}
}
}
}
As you can see in the example above, WatchedHistoryConnector
is a simple value type that we can quickly test using unit testing. Now, we should take a look at how we can use our connector types. Usually, I have container or flow views that connect views to the store.
import SwiftUI
struct RootContainerView: View {
@EnvironmentObject var store: Store<AppState, AppAction>
var body: some View {
HistoryView(
viewModel: store.connect(using: Connectors.WatchedHistoryConnector())
)
}
}
Contacts
I have a clear focus on time-to-market and don't prioritize technical debt. And I took part in the Pre-Sale/RFX activity as a System Architect, assessment efforts for Mobile (iOS-Swift, Android-Kotlin), Frontend (React-TypeScript) and Backend (NodeJS-.NET-PHP-Kafka-SQL-NoSQL). And I also formed the work of Pre-Sale as a CTO from Opportunity to Proposal via knowledge transfer to Successful Delivery.
🛩️ #startups #management #cto #swift #typescript #database
📧 Email: sergey.leschev@gmail.com
👋 LinkedIn: https://linkedin.com/in/sergeyleschev/
👋 LeetCode: https://leetcode.com/sergeyleschev/
👋 Twitter: https://twitter.com/sergeyleschev
👋 Github: https://github.com/sergeyleschev
🌎 Website: https://sergeyleschev.github.io
🌎 Reddit: https://reddit.com/user/sergeyleschev
🌎 Quora: https://quora.com/sergey-leschev
🌎 Medium: https://medium.com/@sergeyleschev
Posted on March 18, 2023
Join Our Newsletter. No Spam, Only the good stuff.
Sign up to receive the latest update from our blog.