Chapters

Hide chapters

Advanced iOS App Architecture

Fourth Edition · iOS 15 · Swift 5.5 · Xcode 13.2

Before You Begin

Section 0: 4 chapters
Show chapters Hide chapters

Section I

Section 1: 9 chapters
Show chapters Hide chapters

6. Architecture: Redux
Written by René Cacheaux & Josh Berlin

Heads up... You're reading this book for free, with parts of this chapter shown beyond this point as scrambled text.

At Facebook, some years ago, a bug in the desktop web app sparked a new architecture. The app presented the unread count of messages from Messenger in several views at once, not always presenting the same amount of unread messages. This could get out of sync and report different numbers, so the app looked broken. Facebook needed a way to guarantee data consistency and, out of this problem, a new unidirectional architecture was born — Flux.

After Facebook moved to a Flux based architecture, views that showed the unread message count got data from the same container. This new architecture fixed a lot of these kinds of bugs.

Flux is a pattern, though, not a framework. In 2015, Dan Abramov and Andrew Clark created Redux as a JavaScript implementation of a Flux inspired architecture. Since then, others have created Redux implementations in languages such as Swift and Kotlin.

What is Redux?

Redux is an architecture in which all of your app’s state lives in one container. The only way to change state is to create a new state based on the current state and a requested change.

The Store holds all of your app’s state.

An Action is immutable data that describes a state change.

A Reducer changes the app’s state using the current state and an action.

Store

The Redux store contains all state that drives the app’s user interface. Think of the store as a living snapshot of your app. Anytime its state changes, the user interface updates to reflect the new state.

Types of state

A Store contains data that represents an app’s user interface (UI). Here are some examples:

Derived values

The store doesn’t contain larger files, such as images or videos. Instead, it contains file URLs pointing to media on disk.

Modeling view state

In a Redux architecture, views store no state. They only listen and react to state changes. So, any state that changes how the view behaves lives in the store. Stores consist of immutable value types. When data in the store changes, views get a new, immutable state to re-render the user interface.

Sign-in screen

Onboarding displays a welcome screen where you can navigate to the sign-in or sign-up screens. The app state determines which screen is currently shown to the user. When the app state changes, the app presents a new screen to the user.

Loading and rendering initial state

Koober has two high-level app states:

Subscription

For a view to render, it subscribes to changes in the store. Each time state changes, the view gets wholesale changes containing the entire state — there is no middle ground. This is unlike MVVM, where you manipulate one property at a time.

Responding to user interactions

Actions are immutable data that describe a state change. You create actions when the user interacts with the user interface.

Threading

In Redux, it’s important to run all the reducers on the same thread. It doesn’t have to be the main thread, but the same serial queue.

Performing side effects

Side effects are any non-pure functions. Any time you call a function that can return a different value given the same inputs is a side effect. Pure functions are deterministic. Given the same inputs, the function always has the same outputs.

Rendering updates

Redux is a “reactive” architecture. The word “reactive” is thrown around a lot these days. In Redux, “reactive” means the view receives updated state via subscriptions, and it “reacts” to the updates. Views never ask the store for the current state; they only update when the store fires an update that data changed.

Diffing

Each time a view receives new state via subscription, it gets the whole state. The view needs to figure out what changed and then properly render the update.

Example: Onboarding to signed-in

Koober has two high-level app states:

Example: Signing in

The sign-in screen contains a Username / Email text field, Password text field and a Sign In button. Tapping the Sign In button signs you in using the username and password inputs.

Applying theory to iOS apps

If we had to guess, you’re probably ready for some Kangaroo-filled code examples after all that theory! Let’s dive into the code and see how Redux actually works in practice.

Redux in iOS apps

ReSwift and Katana are the two main Swift Redux implementations. Both have a strong following on GitHub and are great choices for your Redux library. Redux is a simple concept, and you could write your own Redux library. All the Redux libraries are super small by design. Either way, use of a library is recommended.

Building a view

Before you can hop on a Kangaroo around Sydney, you have to sign in to Koober. You sign in to the app in the sign-in screen, which contains an Email field, Password field and a Sign In button.

View controller

The SignInViewController configures the SignInRootView and observes store state changes.

public class SignInViewController: NiblessViewController {

  // MARK: - Properties
  // ...

  // MARK: - Methods
  init(state: AnyPublisher<SignInViewControllerState, Never>,
       userInteractions: SignInUserInteractions) {
    self.statePublisher = state
    self.userInteractions = userInteractions
    super.init()
  }

  // ...
}

Sign-in view state

Open, SignInViewControllerState.swift inside KooberKit:

public struct SignInViewState: Equatable {

  // MARK: - Properties
  public internal(set) var emailInputEnabled = true
  public internal(set) var passwordInputEnabled = true
  public internal(set) var signInButtonEnabled = true
  public internal(set) var signInActivityIndicatorAnimating
    = false

  // MARK: - Methods
  public init() {}
}
public struct SignInViewControllerState: Equatable {

  // MARK: - Properties
  public internal(set) var viewState = SignInViewState()
  public internal(set) var errorsToPresent: Set<ErrorMessage>
    = []

  // MARK: - Methods
  public init() {}
}

Sign-in user interactions

The SignInUserInteractions protocol describes possible user interactions in the sign-in screen:

public protocol SignInUserInteractions {
  func signIn(email: String, password: Secret)
  func finishedPresenting(_ errorMessage: ErrorMessage)
}

App state

SignInViewControllerState describes the sign-in screen in isolation. But the state is part of a larger state tree.

public enum AppRunningState: Equatable {
	case onboarding(OnboardingState)
	case signedIn(SignedInViewControllerState, UserSession)
}
public enum OnboardingState: Equatable {

  case welcoming
  case signingIn(SignInViewControllerState)
  case signingUp(SignUpViewControllerState)

  // ...
}
public class UserSession: Codable {

  // MARK: - Properties
  public let profile: UserProfile
  public let remoteSession: RemoteUserSession

  // MARK: - Methods
  public init(profile: UserProfile, 
              remoteSession: RemoteUserSession) {
    self.profile = profile
    self.remoteSession = remoteSession
  }
}

public struct RemoteUserSession: Codable, Equatable {

  // MARK: - Properties
  let token: AuthToken

  // MARK: - Methods
  public init(token: AuthToken) {
    self.token = token
  }
}
public struct UserProfile: Equatable, Codable {

  // MARK: - Properties
  public let name: String
  public let email: String
  public let mobileNumber: String
  public let avatar: URL

  // MARK: - Methods
  public init(name: String, 
              email: String, 
              mobileNumber: String, 
              avatar: URL) {
    self.name = name
    self.email = email
    self.mobileNumber = mobileNumber
    self.avatar = avatar
  }
}

Equatable state models

To prevent duplicate calls, make your state models Equatable. Otherwise, multiple calls to UI methods could occur. For example, you could present a view controller over and over again — not a great user experience!

Using Combine to observe ReSwift

Koober abstracts the ReSwift dependency from all user interface code, including UIViewControllers and UIViews. This makes it easier to switch the Redux implementation down the road, since none of the user interface code needs to change. Koober still gets the benefits of ReSwift, though. It still dispatches actions and changes state in pure reducer functions. The difference is Combine drives the user interface updates instead of ReSwift store subscriptions.

ReSwift publishers

Instead of subscribing directly to the ReSwift state store, view controllers in Koober subscribe to Combine publishers created from the ReSwift store. For this to work, a Combine Subscription forwards the ReSwift store subscriber updates to Combine publisher subscribers:

private final class StateSubscription 
  <S: Subscriber, StateT: Any>: 
  Combine.Subscription, StoreSubscriber 
  where S.Input == StateT {

  var requested: Subscribers.Demand = .none
  var subscriber: S?

  let store: Store<StateT>
  var subscribed = false

  init(subscriber: S, store: Store<StateT>) {
    self.subscriber = subscriber
    self.store = store
  }

  func cancel() {
    store.unsubscribe(self)
    subscriber = nil
  }

  func request(_ demand: Subscribers.Demand) {
    requested += demand

    if !subscribed, requested > .none {
      // Subscribe to ReSwift store
      store.subscribe(self)
      subscribed = true
    }
  }

  // ReSwift calls this method on state changes
  func newState(state: StateT) {
    guard requested > .none else {
      return
    }
    requested -= .max(1)

    // Forward ReSwift update to subscriber
    _ = subscriber?.receive(state)
  }
}
extension Store where State: Equatable {

  public func publisher() -> AnyPublisher<State, Never> {
    return StatePublisher(store: self).eraseToAnyPublisher()
  }

  //...

}

Focusing the publisher

Each view only cares about a subset of the ReSwift store’s state tree. For example, the user profile screen displays user information and knows nothing about the map. There’s no point for the store to notify the profile screen when the user’s location changes or more Kangaroos become available for a ride.

public class 
  ProfileContentViewController: NiblessViewController {

  // MARK: - Properties
  // State
  let statePublisher: 
    AnyPublisher<ProfileViewControllerState, Never>
  var subscriptions = Set<AnyCancellable>()

  // User Interactions
  let userInteractions: ProfileUserInteractions
  
  // ...
}
// ...

public func makeProfileViewControllerStatePublisher() -> 
  AnyPublisher<ProfileViewControllerState, Never> {

  let statePublisher = stateStore.publisher { subscription in
    subscription.select(self.signedInGetters
                            .getProfileViewControllerState)
  }

  return statePublisher
}

// ...

Scoped state

When using enums to model app state, views might be observing state that goes out of scope. When an enum case changes, some part of the state tree goes away. For example, in the pick-me-up flow, there’s an enum for the step of the ride request the user engaged in. As the user moves through the cases, anything observing an associated value in a changed case goes out of scope. In practice, you don’t ever want to observe an out-of-scope state. Going out of scope means a view controller is living longer than you designed it to live for.

public enum ScopedState<StateType: Equatable>: Equatable {
  case outOfScope
  case inScope(StateType)
}
// ...

func newState(state: ScopedState<SelectedStateT>) {
  guard requested > .none else {
    return
  }
  requested -= .max(1)

  switch state {
  case let .inScope(inScopeState):
    _ = subscriber?.receive(inScopeState)
  case .outOfScope:
    _ = subscriber?.receive(completion: .finished)
  }
}

// ...

// ...

func getProfileViewControllerState(appState: AppState)
  -> ScopedState<ProfileViewControllerState> {

  let signedInScopedState = getSignedInState(appState)
  guard case .inScope(let signedInViewControllerState) =
    signedInScopedState
  else {
      return .outOfScope
  }

  return .inScope(signedInViewControllerState
                  .profileViewControllerState)
}

// ...

User session persistence

Koober persists the user session on disk between sessions. On launch, the app reads the user session from persistence. If it exists, the user is authenticated and can request rides. If it doesn’t exist, the user must go through the onboarding flow.

public class LaunchViewController: NiblessViewController {

  // MARK: - Properties
  // User Interactions
  let userInteractions: LaunchingUserInteractions

  // State
  let statePublisher: 
    AnyPublisher<LaunchViewControllerState, Never>
  var subscriptions = Set<AnyCancellable>()

  // MARK: - Methods
  // ...
  public override func viewDidLoad() {
    super.viewDidLoad()
    observeState()
    userInteractions.launchApp()
  }
  // ...
}
public protocol LaunchingUserInteractions {
  func launchApp()
  func finishedPresenting(errorMessage: ErrorMessage)
}
public class ReduxLaunchingUserInteractions: 
  LaunchingUserInteractions {

  // MARK: - Properties
  let actionDispatcher: ActionDispatcher
  let userSessionDataStore: UserSessionDataStore
  let userSessionStatePersister: UserSessionStatePersister

  // MARK: - Methods
  // ...

  public func launchApp() {
    loadUserSession()
  }

  // ...

  private func loadUserSession() {
    userSessionDataStore.readUserSession()
      .done(finishedLaunchingApp(userSession:))
      .catch { error in
        let errorMessage = 
          ErrorMessage(title: "Sign In Error",
                       message: """
                         Sorry, we couldn't determine \
                         if you are already signed in.
                         Please sign in or sign up.
                       """)
        self.present(errorMessage: errorMessage)
    }
  }

  private func finishedLaunchingApp(userSession: UserSession?) {
    let authenticationState = 
      AuthenticationState(userSession: userSession)
    let action = 
      LaunchingActions.FinishedLaunchingApp(authenticationState:
        authenticationState)

    actionDispatcher.dispatch(action)
    
    userSessionStatePersister
      .startPersistingStateChanges(to: userSessionDataStore)
  }

  // ...
}
public class ReduxUserSessionStatePersister: 
  UserSessionStatePersister {

  // MARK: - Properties
  let authenticationStatePublisher: 
    AnyPublisher<AuthenticationState?, Never>
  var subscriptions = Set<AnyCancellable>()

  // MARK: - Methods
  public init(reduxStore: Store<AppState>) {
    let runningGetters = 
      AppRunningGetters(getAppRunningState: 
        EntryPointGetters().getAppRunningState)

    self.authenticationStatePublisher =
      reduxStore.publisher { subscription in
        subscription
          .select(runningGetters.getAuthenticationState)
      }
      .removeDuplicates()
      .eraseToAnyPublisher()
  }

  // ...
}
public enum AuthenticationState: Equatable {
  
  case notSignedIn
  case signedIn(UserSession)

  init(userSession: UserSession?) {
    if let userSession = userSession {
      self = .signedIn(userSession)
    } else {
      self = .notSignedIn
    }
  }
}
// ...

public func startPersistingStateChanges(
  to userSessionDataStore: UserSessionDataStore) {

  self.authenticationStatePublisher
    .receive(on: DispatchQueue.main)
    .dropFirst(1)
    .sink { [weak self] authenticationState in
      self?.on(authenticationState: authenticationState, 
               with: userSessionDataStore)
    }
    .store(in: &subscriptions)
}

// ...

Responding to user interaction

View controllers in Koober declare all possible user interactions in a user interactions protocol. The implementation of the class gets injected on initialization. Most user interactions result in a modification to the store. You can think of the user interaction objects like view models in MVVM.

public protocol SignInUserInteractions {
  func signIn(email: String, password: Secret)
  func finishedPresenting(_ errorMessage: ErrorMessage)
}
public class ReduxSignInUserInteractions: 
  SignInUserInteractions {

  // MARK: - Properties
  let actionDispatcher: ActionDispatcher
  let remoteAPI: AuthRemoteAPI

  // MARK: - Methods
  public init(actionDispatcher: ActionDispatcher,
              remoteAPI: AuthRemoteAPI) {
    self.actionDispatcher = actionDispatcher
    self.remoteAPI = remoteAPI
  }

  // ...
}
protocol ActionDispatcher {
  func dispatch(_ action: Action)
}

extension Store: ActionDispatcher {}
let action = SignOutAction()
store.dispatch(action)
let action = SignOutAction()
actionDispatcher.dispatch(action)
// ...

public func signIn(email: String, password: Secret) {
  indicateSigningIn()
  remoteAPI.signIn(email: email, password: password)
    .done(signedIn(to:))
    .catch(indicateErrorSigningIn)
}

private func indicateSigningIn() {
  let action = SignInActions.SigningIn()
  actionDispatcher.dispatch(action)
}

private func signedIn(to userSession: UserSession) {
  let action = SignInActions.SignedIn(
    userSession: userSession
  )
  actionDispatcher.dispatch(action)
}

private func indicateErrorSigningIn(error: Error) {
  let errorMessage = ErrorMessage(
    title: "Sign In Failed",
    message: "Could not sign in.\nPlease try again."
  )
  let action = SignInActions.SignInFailed(
    errorMessage: errorMessage
  )
  actionDispatcher.dispatch(action)
}

// ...

Rendering updates

Actions describe a state change. Let’s look at what makes up an action:

struct SignInActions {
  // Internal
  struct SigningIn: Action {}

  struct SignInFailed: Action {
    let errorMessage: ErrorMessage
  }

  struct FinishedPresentingError: Action {
    let errorMessage: ErrorMessage
  }

  // External
  struct SignedIn: Action {
    let userSession: UserSession
  }
}
extension Reducers {

  static func signInReducer(
    action: Action,
    state: SignInViewControllerState?)
    -> SignInViewControllerState {

    var state = state ?? SignInViewControllerState()

    switch action {
    case _ as SignInActions.SigningIn:
      SignInLogic.indicateSigningIn(
        viewState: &state.viewState)
  	// Handle other cases here.
        // ...
    default:
      break
    }

    return state
  }
}
struct SignInLogic {

  // MARK: - Methods
  static func indicateSigningIn(viewState: 
    inout SignInViewState) {
    
    viewState.emailInputEnabled = false
    viewState.passwordInputEnabled = false
    viewState.signInButtonEnabled = false
    viewState.signInActivityIndicatorAnimating = true
  }

  // ...
}

Communicating amongst views

In Redux, there is no direct communication between views. They observe state from the same store, so one view controller can affect another by dispatching an action. The reducer updating the store can change state another view controller is observing.

Pick-me-up screen

The PickMeUpViewController contains the meat of the Koober app. It displays the map, the Where To? button and the ride-option picker.

enum PickMeUpView: Equatable {
  case initial
  case selectDropoffLocation
  case selectRideOption
  case confirmRequest
  case sendingRideRequest(NewRideRequest)
  case final
}
public class PickMeUpViewController: NiblessViewController {

  // MARK: - Properties
  // Child View Controllers
  let mapViewController: PickMeUpMapViewController
  let rideOptionPickerViewController:
    RideOptionPickerViewController
  let sendingRideRequestViewController:
    SendingRideRequestViewController

  // State
  let statePublisher: 
    AnyPublisher<PickMeUpViewControllerState, Never>
  var subscriptions = Set<AnyCancellable>()

  // User Interactions
  let userInteractions: PickMeUpUserInteractions

  // Factories
  let viewControllerFactory: PickMeUpViewControllerFactory

  // ...
}
public struct PickMeUpViewControllerState: Equatable {

  public internal(set) var pickupLocation: Location
  public internal(set) var state: PickMeUpState
  // Other states go here.
  // ...
}
public enum PickMeUpState: Equatable {

  case initial
  case selectDropoffLocation(
    DropoffLocationPickerViewControllerState
  )
  // Other states go here.
  // ...
}
// ...

func presentDropoffLocationPicker() {
  let viewController = viewControllerFactory
    .makeDropoffLocationPickerViewController()

  present(viewController, animated: true)
}

// ...
class PickMeUpRootView: NiblessView {

  // MARK: - Properties
  let userInteractions: PickMeUpUserInteractions

  let whereToButton: UIButton = {
    let button = UIButton(type: .system)
    // ...
    return button
  }()

  // MARK: - Methods
  init(frame: CGRect = .zero, 
       userInteractions: PickMeUpUserInteractions) {
    self.userInteractions = userInteractions

    super.init(frame: frame)

    addSubview(whereToButton)
    bindWhereToControl()
  }
  
  // ...

  @objc
  func goToDropoffLocationPicker() {
    userInteractions.goToDropoffLocationPicker()
  }

  // ...
}
public class ReduxPickMeUpUserInteractions: 
  PickMeUpUserInteractions {

  // MARK: - Properties
  let actionDispatcher: ActionDispatcher
  let newRideRepository: NewRideRepository

  // MARK: - Methods
  public init(actionDispatcher: ActionDispatcher,
              newRideRepository: NewRideRepository) {
    self.actionDispatcher = actionDispatcher
    self.newRideRepository = newRideRepository
  }

  public func goToDropoffLocationPicker() {
    let action = PickMeUpActions.GoToDropoffLocationPicker()
    actionDispatcher.dispatch(action)
  }

  // ...
}
struct PickMeUpActions {

  struct GoToDropoffLocationPicker: Action {}

  // ...
}
extension Reducers {

  static func pickMeUpReducer(
    action: Action,
    state: PickMeUpViewControllerState) 
    -> PickMeUpViewControllerState {
  
    var state = state

    switch action {
    case _ as PickMeUpActions.GoToDropoffLocationPicker:
      let initialDropoffLocationViewControllerState =
        DropoffLocationPickerViewControllerState(
          pickupLocation: state.pickupLocation,
          searchResults: [],
          currentSearchID: nil,
          errorsToPresent: [])

      state.state = .selectDropoffLocation(
          initialDropoffLocationViewControllerState)
      // Other actions handled here.
      // ...
    }

    // ...

    return state
   }
}
public class PickMeUpViewController: NiblessViewController {

  // MARK: - Properties
  // ...
  // State
  let statePublisher: 
    AnyPublisher<PickMeUpViewControllerState, Never>
  var subscriptions = Set<AnyCancellable>()
  // ...

  // MARK: - Methods
  // ...
  func observeState() {
    // ...
    statePublisher
      .receive(on: DispatchQueue.main)
      .map { (state: $0.state, sendingState: $0.sendingState) }
      .map (mapToView)
      .removeDuplicates()
      .sink { [weak self] view in
        self?.present(view)
      }
      .store(in: &subscriptions)
    // ...
  }

  func present(_ view: PickMeUpView) {
    switch view {
    case .initial:
      presentInitialState()
    case .selectDropoffLocation:
      presentDropoffLocationPicker()
    // Handle other view cases.
    // ...
    }
  }
  // ...
}

Selecting a ride option

Koober has a wide variety of ride option types to choose from:

public class PickMeUpViewController: NiblessViewController {

  // MARK: - Properties
  // ...

  // MARK: - Methods
  // ...
  func present(_ view: PickMeUpView) {
    switch view {
    case .initial:
      presentInitialState()
    case .selectDropoffLocation:
      presentDropoffLocationPicker()
    case .selectRideOption:
      dropoffLocationSelected()
    // Other cases handled here.
    // ...
    }
  }

  // ...

  func dropoffLocationSelected() {
    if presentedViewController is
      DropoffLocationPickerViewController {
      
      dismiss(animated: true)
    }

    presentRideOptionPicker()
  }

  // ...
}
public class RideOptionPickerViewController: 
  NiblessViewController {

  // MARK: - Properties
  // Dependencies
  let imageCache: ImageCache

  // State
  let statePublisher: 
    AnyPublisher<RideOptionPickerViewControllerState, Never>
  let pickupLocation: Location
  var selectedRideOptionID: RideOptionID?
  var subscriptions = Set<AnyCancellable>()

  // User Interactions
  let userInteractions: RideOptionPickerUserInteractions

  // ...
}
class RideOptionSegmentedControl: UIControl {

  // MARK: - Properties
  let userInteractions: RideOptionPickerUserInteractions

  var viewState = 
    RideOptionSegmentedControlState() {
      didSet {
        if oldValue != viewState {
          loadAndRecreateButtons(withSegments: 
            viewState.segments)
        } else {
          update(withSegments: viewState.segments)
        }
      }
    }

  // ...

  // MARK: - Methods
  // ...
  // Called to create a new ride option button
  private func makeRideOptionButton(
    forSegment segment: RideOptionSegmentState)
    -> (RideOptionID, RideOptionButton) {
    
    let button = RideOptionButton(segment: segment)
    button.didSelectRideOption = { [weak self] id in
      self?.userInteractions.select(rideOptionID: id)
    }
    return (segment.id, button)
  }
}
public struct RideOptionSegmentedControlState: Equatable {

  // MARK: - Properties
  public var segments: [RideOptionSegmentState]

  // MARK: - Methods
  public init(segments: [RideOptionSegmentState] = []) {
    self.segments = segments
  }
}
public struct RideOptionSegmentState: Equatable {

  // MARK: - Properties
  public var id: String
  public var title: String
  public var isSelected: Bool
  public var images: ButtonRemoteImages

  // MARK: - Methods
  public init(id: String,
              title: String,
              isSelected: Bool,
              images: ButtonRemoteImages) {
    self.id = id
    self.title = title
    self.isSelected = isSelected
    self.images = images
  }

  // ...
}
public protocol RideOptionPickerUserInteractions {

  func loadRideOptions(
    availableAt pickupLocation: Location,
    screenScale: CGFloat)
  func select(rideOptionID: RideOptionID)
  func finishedPresenting(_ errorMessage: ErrorMessage)
}
public class ReduxRideOptionPickerUserInteractions: 
  RideOptionPickerUserInteractions {

  // MARK: - Properties
  let actionDispatcher: ActionDispatcher
  let rideOptionRepository: RideOptionRepository

  // MARK: - Methods
  // ...
  public func select(rideOptionID: RideOptionID) {
    let action = RideOptionPickerActions
      .RideOptionSelected(rideOptionID: rideOptionID)
    actionDispatcher.dispatch(action)
  }
  
  // ...
}
struct RideOptionPickerActions {

  // ...

  struct RideOptionSelected: Action {

    // MARK: - Properties
    let rideOptionID: RideOptionID
  }
 
  // ...
}
extension Reducers {

  static func rideOptionPickerReducer(
    action: Action,
    state: RideOptionPickerViewControllerState?) 
    -> RideOptionPickerViewControllerState {
    
    var state = state ?? 
      RideOptionPickerViewControllerState(
        segmentedControlState: 
          RideOptionSegmentedControlState(segments: []),
        errorsToPresent: [])

    switch action {
    // ...
    case let action as
      RideOptionPickerActions.RideOptionSelected:

      var segments = state.segmentedControlState.segments
      for (index, segment)
        in state.segmentedControlState.segments.enumerated() {

        segments[index].isSelected =
          (segment.id == action.rideOptionID)
      }

      state.segmentedControlState.segments = segments
    // Handle other actions.
    // ...
    default:
      break
    }

    return state
  }
}
public class RideOptionPickerViewController: 
  NiblessViewController {

  // MARK: - Properties
  // ...

  // MARK: - Methods
  // ...
  func observeState() {
    statePublisher
      .receive(on: DispatchQueue.main)
      .map { $0.segmentedControlState }
      .removeDuplicates()
      .sink { [weak self] segmentedControlState in
        self?.rideOptionSegmentedControl.viewState = 
          segmentedControlState
      }
      .store(in: &subscriptions)
    // ...
  }
  // ...
}

Pros and cons of Redux

Pros of Redux

  1. Redux scales well as your application grows — if you follow best practices. Separate your Redux store state into sub-states and only observe partial state in your view controllers.
  2. Descriptive state changes are all contained in reducers. Any developer can read through your reducer functions to understand all state changes in the app.
  3. The store is the single source of truth for your entire app. If data changes in the store, the change propagates to all subscribers.
  4. Data consistency across screens is good for iPad apps and other apps that display the same data in multiple places at the same time.
  5. Reducers are pure functions — they are easy to test.
  6. Redux architecture, overall, is easy to test. You can create a test case by putting the app in any app state you want, dispatch an action and test that the state changed correctly.
  7. Redux can help with state restoration by initializing the store with persisted state.
  8. It’s easy to observe what’s going on in your app because all the state is centralized to the store. You can easily record state changes for debugging.
  9. Redux is lightweight and a relatively simple high-level concept.
  10. Redux helps separate side effects from business logic.
  11. Redux embraces value types. State can’t change from underneath you.

Cons of Redux

  1. You need to touch multiple files to add new functionality.
  2. Requires a third-party library, but the library is very small.
  3. Model layer knows about the view hierarchy and is sensitive to user-interface changes.
  4. Redux can use more memory than other architectures since the store is always in memory.
  5. You need to be careful with performance because of possible frequent deep copies of the app state struct.
  6. Dispatching actions can result in infinite loops if you dispatch actions in response to state changes.
  7. Data modeling is hard. Benefits of Redux depend on having a good data model.
  8. It is designed to work with a declarative user interface framework like React. This can be awkward to apply to UIKit because UIKit is imperative. This isn’t a blocker, just that it’s not a natural fit.
  9. Since the entire app state is centralized, it’s possible to have reducers that depend on each other. That removes modularity and encapsulation of a model / screen / component’s state. So refactoring a component’s state type could cause complier issue elsewhere and this is not good. You won’t run into this if you organize your reducers to only know about a module’s state and no more. This is not constrained by the architecture, though, so it depends on everyone being aware.

Key points

Where to go from here?

Koober is meant to be a production app, and there’s lots more code in the sample project to explore.

Have a technical question? Want to report a bug? You can ask questions and report bugs to the book authors in our official book forum here.
© 2024 Kodeco Inc.

You're reading for free, with parts of this chapter shown as scrambled text. Unlock this book, and our entire catalogue of books and videos, with a Kodeco Personal Plan.

Unlock now