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

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.

You might think storing everything in one place is insane — that’s a valid thought. Instead of creating one massive file for the state, split it up into different sub-states. Each screen cares about a part of the entire apps state, anyway. We’ll talk more about keeping the store organized in the example code section of this chapter.

Types of state

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

  • View state determines which user elements to show, hide, enable, disable or whether a spinner is animating.

  • Navigation state determines which view to present to the user and which views are currently presented.

  • High-level state determines whether the user is signed in or signed out. Current user profile metadata and authentication tokens could be contained in the high-level state.

  • Data from web services include things like responses from a REST API. The response gets parsed into models and placed in the store. In Koober, the available ride options displayed on the map live in the store.

  • Formatted strings are strings that get transformed for display from raw model data from an API.

The store is the source of truth for your app. All views get data from the same store, so there’s no chance of two views displaying different data, as was happening during Facebook’s bug.

Derived values

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

The entire store is in memory at all times. If your app has tons of video files or images in the store instead of file references, iOS may crash your app to free up memory.

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.

The Onboarding state shows the unauthenticated screens before the user logs in. The Signed In state shows the authenticated screens after the user logs in.

The Onboarding state contains three states:

  1. Welcoming
  2. Signing In
  3. Signing Up

Welcoming displays the welcome screen, which has Sign In and Sign Up buttons. When you tap the Sign In button, you set the app state to Signing In. When you tap the Sign Up button, you set the app state to Signing Up.

At any moment, you can look at the state of the Redux store to determine what screen the user interface is presenting.

Loading and rendering initial state

Koober has two high-level app states:

  • Launching loads any data that the app needs to function, like a previous user session.

  • Running displays either the onboarding flow or the map screen.

The Running state has two sub-states:

  • Onboarding displays the sign-in or sign-up screen so that the user can authenticate.

  • Signed In displays the map and needs a valid user session.

Koober starts in the “launching” state.

Once the “launching” state reads the user session, the app transitions to the “running” state. Next, the user interface displays either the onboarding flow or the signed-in screens. The Redux store always has an initial state and never has an invalid state. Redux forces you to declare every possible state for your app.

If you don’t persist data between launches, that data must have an initial default in the store. For example, Koober has ride options you can choose before requesting a ride: Wallaby, Wallaroo and Kangaroo. These values can change, so they come from the server. Before you download them, the initial state in the Redux store is an empty array. The user interface should be able to gracefully handle this empty state.

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.

Using Focused Observation, the view can subscribe to pieces of state that it’s interested in, avoiding updates when any app state changes occur. The view still gets the piece of state in one update.

Views need the current state from the store each time the view loads. On load, they always have an empty state. After subscribing to the store, it fires an update and the view re-renders.

There’s a short delay between when the app presents the view on screen and when the subscription fires its first update. The duration is usually short enough where you don’t notice the first update. But make sure all views can gracefully display an empty state.

Responding to user interactions

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

Dispatching an action is the only way to change state in the Redux store. No sneaky view can grab the store and make changes without the rest of the app finding out. Redux works because actions change the store, and it notifies subscribers across the app.

For example, in the Welcome screen, there are two buttons, Sign In and Sign Up. When the Sign Up button gets tapped, you create and dispatch a Go to Sign Up action. The store updates its state, and it notifies the OnboardingViewController. Then, the OnboardingViewController pushes the sign-up screen onto the navigation stack.

Reducers describe possible state changes. Reducers are the step between dispatching an action and changing the store’s state. After an action is dispatched, it travels through a reducer. The only place the store’s state can mutate is in a reducer. Reducers are free functions that take in the current store’s state along with an action describing a state. They mutate a copy of the current state based on the action, and return the new state. Reducer functions should not introduce side effects. They should not make API calls or modify objects outside of their scope.

In addition to updating state based on actions, reducers can run business logic to transform state. Date formatting logic lives in reducers to transform data for display. For example, a reducer can transform a Date object to a presentable String.

Koober contains a lot of logic in reducers. It’s already enough trouble keeping view controllers small. The last thing you need is a massive reducer file.

Redux recommends to split your reducers into sub-reducers. Sub-reducers help keep your reducer logic focused and readable. Koober has sub-reducers for the onboarding flow, the sign-up screen, the sign-in screen and so on.

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.

If you run the reducers on multiple threads, the input state of the reducer could change while it’s running on another thread. Redux is a synchronization point by design.

ReSwift, a Redux implementation of unidirectional data flow architecture in Swift, lets you run reducers on any serial queue, but defaults to the main queue. The simplest approach is to run on the main queue because the user interface and store are completely in sync. Then, there’s no need to hop on main queue when observing the store.

Note: In a complex app, reducers might take some time. In this case, it can be a good idea to run reducers on another serial queue that’s not the main queue. Most of the time, the main queue is fine.

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.

Reducers should be pure functions, free of side effects. In Redux, you handle side effects before dispatching actions and after the store updates.

For example, apps commonly make asynchronous API calls to a server and wait for a response. In Redux, you never make these asynchronous API calls in reducer functions. Instead, create multiple actions for different stages of your network request.

Stages of a network request:

  1. Network request is in progress.
  2. Network request completed successfully.
  3. Network request failed.

Before starting the network request, dispatch an In-progress action. The reducer updates the state in the store to indicate the network request is in-progress. The view updates its user interface to reflect the change by showing a spinner and disabling UI elements as needed.

Next, make the network request. Once the API call completes, dispatch a Network Request Succeeded or Network Request Failed action. The store updates its state, and the view updates to show a success or failed message, and enables its UI elements. You can also dispatch actions during the network requests to update percentage complete state in the store.

A network request is one example of an asynchronous operation, and any asynchronous task can follow the same process: dispatch actions before, during and after the task completes.

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.

The simple solution is to reload the entire UI, although this might look clunky. Another solution is to diff the new state with the current state of the UI and render necessary updates.

Diffing helps avoid unnecessary changes. It also allows views to animate changes, since you know exactly which user interface element changed.

UIKit sometimes won’t render unnecessary changes. You can test this by subclassing a UIView, set a property to some test value, and check if the system calls draw rect or needs display.

Example: Onboarding to signed-in

Koober has two high-level app states:

  • Onboarding displays the sign-in or sign-up screen when the user is not authenticated.

  • Signed-in displays the map screen after the user signs in.

Koober handles the transition from onboarding to signed-in in the main view — a container view that can display the sign-in screen or the map screen.

Going through each of the sign-in steps:

  1. Main view initially presents the sign-in screen.
  2. The user enters an email and password and then taps the Sign In button.
  3. The Sign-in view tells its user interaction object to sign the user in to Koober.
  4. The user interaction object asks its repository to make the sign-in API call.
  5. Once the API call completes, the user interactions object dispatches a Signed-in action containing the new user session.
  6. The store notifies the main view to transition to Signed-in and display the map.

Next, let’s look at the Sign-in view in more detail.

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.

If a sign in succeeds, an action gets dispatched containing the new user session. If sign in fails, an action gets dispatched containing the Error message to present.

The sign-in state contains four Boolean values and error messages:

  1. Email input enabled.
  2. Password input enabled.
  3. Sign In button enabled.
  4. Sign-in activity indicator animating.
  5. A list of errors to present.

The sign-in screen dispatches actions on user interaction, which updates the sign-in state in the store. Redux broadcasts the new state and the sign-in screen reacts by updating its user interface.

The sign-in screen can dispatch four actions:

  1. Signing in to signal a sign-in operation is in progress.
  2. Sign-in failed to signal sign-in failed along with the error message.
  3. Finished presenting error to signal the user has acknowledged the error.
  4. Signed in to signal the sign-in succeeded with a valid user session.

Let’s go through each in more detail.

Signing In Action is dispatched after the user enters a username and password, and taps the Sign In button.

The signing-in action gets dispatched before you make the call to the Koober API to sign in, and the reducer updates the store’s state.

Then, the store notifies the view the state changed to signing in.

Sign-in failed action is dispatched after the Koober API returns a failed response from the sign-in call. The state change includes the error message for the view to present.

Finished presenting error action is dispatched after the view dismisses the error message. This state is important when you want to modify the user interface while the error is displayed on screen. You might also want to present a second error only after the user dismisses the first error.

Signed in action is dispatched after the Koober API returns a successful response. In this state, the sign-in screen can transition to a success state which includes the valid user session object.

After the user successfully signs in to Koober, the sign-in screen has no more responsibilities. The main view transitions the app from the unauthenticated state to the authenticated state, and shows the map screen.

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.

Koober uses ReSwift since it’s the most established library. For more details about ReSwift, check out the GitHub repo located at https://github.com/ReSwift/ReSwift.

Note: Most of the code snippets are subsets of the full files. Feel free to open 06-architecture-redux/final/KooberApp/KooberApp.xcodeproj while reading if you’d like to follow along and check out the full source.

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.

Tapping the Sign In button makes an authentication call to the Koober API and signs you in to the app.

View controller

The SignInViewController configures the SignInRootView and observes store state changes.

Let’s take a look at the SignInViewController initializer:

public class SignInViewController: NiblessViewController {

  // MARK: - Properties
  // ...

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

  // ...
}

SignInViewController has two initializer dependencies:

  • A Combine publisher to subscribe to SignInViewControllerState changes.

  • A SignInUserInteractions object to handle user interactions in the SignInRootView.

You’ll notice there are no ReSwift dependencies in the view controller. You can abstract ReSwift away from view controllers so that you can change libraries or paradigms without needing to refractor view layer code. This section walks you through how to do that.

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() {}
}

The SignInViewState describes all states of the SignInRootView. The first three Boolean values determine if user interactions are possible in the root view. The signInActivityIndicatorAnimating value determines if the activity indicator is spinning or hidden.

public struct SignInViewControllerState: Equatable {

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

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

The SignInViewControllerState encapsulates the root view state and error handling.

The view controller gets its own state because it presents errors using view controller presentation APIs.

The error messages are a collection in case there are multiple error messages to display in succession.

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)
}

The user can sign in by tapping the Sign In button after entering an email and password. Once the user taps the button, the signIn(email:password:) method gets called.

If signing in fails, the view controller displays an error on the screen. After the user dismisses the error, or the error dismisses after a short period of time, the finishedPresenting(_:) method gets called.

The sign-in view controller has no clue about the underlying implementations for these methods. The view controller gets a concrete instance of SignInUserInteractions on initialization.

App state

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

As you might remember from the Example: Onboarding to signed in section, Koober has two high-level app states.

In the Onboarding state, the user is unauthenticated, and Koober can present the sign-up or sign-in screen.

In the Signed In state, the user has authenticated in the sign-up or sign-in flow.

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

AppRunningState describes the high level app states. Each state has its own sub-state which contains extra information.

Let’s go over the OnboardingState first:

public enum OnboardingState: Equatable {

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

  // ...
}

The Onboarding flow has three states:

  • Welcoming displays the welcome screen, which can navigate to the sign-up or sign-in screen.

  • Signing in lets you sign in as an existing user. The SignInViewControllerState describes the possibles states isolated to the sign-in screen. These are described above in sign-in view state.

  • Signing up lets you sign up as a new user. The SignUpViewControllerState describes the possible states isolated to the sign-up screen.

The signed-in state is the authenticated state, where you can request a Koober on the map. The important piece of the state in the .signedIn app running state is the UserSession.

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
  }
}

The UserSession object contains information about the current authenticated user, like the authentication token, name and avatar URL.

The signed-in state always has a valid user session — it’s a dependency of the .signedIn state. If the user logs out, the user session gets destroyed, and the app switches back to the .onboarding state.

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!

Make sure your state enums with associated values behave properly when compared. Swift 4.2 and later handles auto synthesizing Equatable and Hashable for most models.

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)
  }
}

A StateSubscription handles the ReSwift store subscription. After subscribing to the store, ReSwift calls newState(state:) when state changes and forwards it to the Combine subscriber’s receive(_:) method.

Publishers get created in an extension on the ReSwift store:

extension Store where State: Equatable {

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

  //...

}

publisher() creates and returns a StatePublisher that creates a StateSubscription that gets called whenever the ReSwift State changes. In Koober, publishers get injected into view controllers. View controllers subscribe to the publisher, the publisher creates a Combine subscription, the Combine subscription subscribes to the ReSwift store, and then the view controllers receive updates when the ReSwift store state changes.

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.

The profile screen only needs to re-render when user profile data changes.

The flow now looks like this:

Let’s follow the code path for creating the user profile screen’s focused publisher:

public class 
  ProfileContentViewController: NiblessViewController {

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

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

The ProfileContentViewController has two dependencies:

  • An AnyPublisher that publishes values of type ProfileViewControllerState, a small subset of Koober’s AppState.

  • A ProfileUserInteractions to handle user interactions such as signing out and closing the screen.

ReSwift allows you to subscribe to a subset of the Store using the select() method on ReSwift’s Subscription class. When you subscribe to the Redux Store without using select(), you subscribe to the entire app’s state.

For the ProfileContentViewController, you “select” only the ProfileViewControllerState from the larger AppState to create the publisher.

The publisher is created in the Koober_iOS target. Specifically, in the KooberSignedInDependencyContainer. This dependency container creates publishers and other view controller dependencies in the authenticated app state.

You can find the full implementation at Koober_iOS/iOSApp/SignedIn/KooberSignedInDependencyContainer.swift.

// ...

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

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

  return statePublisher
}

// ...

This method passes in a custom subscription to create the publisher. The custom subscription “selects” only the ProfileViewControllerState, and the publisher fires only when that state changes.

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.

The ability to detect when you go out of scope helps detect bugs. Scoping is necessary because Combine subscriptions observe associated values in an enum case, and the Combine subscription has to be able to handle when that enum case is no longer set.

You could make the Combine publisher-subscription data type optional, but then your view controller can live across scopes. A view controller for one user could suddenly be sent data for another user after logging out and in. Handling the optional case everywhere is also a pain and makes the code less readable.

The Combine subscriptions in Koober observe ScopedState, which is either .outOfScope or .inScope with the StateType wrapped inside:

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

Once the state which a view controller is observing goes out of scope, the Combine subscription finishes - no more events will flow through it:

// ...

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)
  }
}

// ...

When the ProfileViewControllerState publisher is created by the dependency container, the publisher is set to observe the value returned from the getProfileViewControllerState(appState:) method:

// ...

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

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

  return .inScope(signedInViewControllerState
                  .profileViewControllerState)
}

// ...

If the signedInViewControllerState doesn’t exist, the subscription won’t fire. If the signedInViewControllerState exists, the subscription fires with the new ProfileViewControllerState value.

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.

LaunchViewController handles the initial app launch. Since the operation to read the user session is asynchronous, the launch screen displays until the read operation completes.

MainViewController handles the transitions between LaunchViewController, OnboardingViewController and SignedInViewController.

First, MainViewController presents the LaunchViewController which looks like this:

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)
}

LaunchViewController has a LaunchingUserInteractions property that performs the initial app setup in launchApp(). The LaunchingUserInteractions object also handles errors in finishedPresenting(errorMessage:).

LaunchViewController calls launchApp() immediately after it loads.

ReduxLaunchingUserInteractions is the concrete implementation of LaunchingUserInteractions:

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)
  }

  // ...
}

The user interactions object reads the persisted user session in loadUserSession() from the injected UserSessionDataStore.

If the user session is read without errors, it’s passed to finishedLaunchingApp(userSession:). This method dispatches a FinishedLaunchingApp action containing the user session if found or empty if not.

At the end of the method, the user interaction object asks the UserSessionStatePersister to start persisting changes to the user session. When the user signs in or signs up, the persister saves the user session to disk. When the user signs out, the persister removes the user session from the data store.

The trick is the persister can’t start observing right away. The app needs to load the initial state from disk first in ReduxLaunchingUserInteractions.

Let’s look at how the persister gets initialized:

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
    }
  }
}

The ReduxUserSessionStatePersister is created with a store, and creates an AuthenticationState? publisher on init to monitor user session changes. The persister doesn’t subscribe to the publisher until startPersistingStateChanges(to:) gets called.

The AuthenticationState? publisher emits the current state when subscribing and we don’t want to persist what is already the current state. The subscription needs a .dropFirst(1) to skip the first state event:

// ...

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)
}

// ...

This method subscribes to the AuthenticationState? publisher and updates UserSessionDataStore when authentication state changes.

Here’s a diagram of the user session persistence flow:

Let’s review each step:

  1. The launch view controller calls launchApp() on its user interactions object.
  2. Launch user interactions loads the user session from the data store.
  3. After the load completes, the user interactions object tells the persister to start saving user session changes to the data store.
  4. The persister starts observing the AuthenticationState publisher.
  5. Anytime the auth state changes, the persister saves or removes the user session from the data store.

That’s it! The persister ensures the data store is always up to date so the user session is ready to load on the next launch.

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.

The only place actions get dispatched in Koober are in user interactions objects.

Let’s take a deeper look at the SignInUserInteractions protocol mentioned in the Sign-in user interactions section above:

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

The implementation for the SignInUserInteractions is in the ReduxSignInUserInteractions.swift file in KooberKit:

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
  }

  // ...
}

The user interactions object has two dependencies:

  • An ActionDispatcher dispatches actions to the store.

  • An AuthRemoteAPI makes the sign-in API call.

First, let’s look at the ActionDispatcher.

The action dispatcher is a protocol that exposes the ReSwift store’s dispatch action method:

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

extension Store: ActionDispatcher {}

Of course, you could dispatch actions directly to the store:

let action = SignOutAction()
store.dispatch(action)

This works, but you would have to inject the store into all your user interaction objects. You don’t want to give them access to all the store’s methods.

Instead, user interactions object dispatch actions using the dispatcher like this:

let action = SignOutAction()
actionDispatcher.dispatch(action)

Next, let’s look at how the ReduxSignInUserInteractions signs in a user:

// ...

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)
}

// ...

The sign-in view has three main states:

  1. The sign-in request is in progress.
  2. The sign-in request completed with a success.
  3. The sign-in request completed with a failure.

First, the sign-in method calls indicateSignIn() which dispatches a SigningIn action. This indicates the request is in progress and the user interface can show a spinner and disable user interaction.

Next, the user interactions object signs in the user using the remote API.

If the request succeeds, the user interactions object dispatches a SignedIn action containing the new UserSession object. The app dismisses the sign-in screen and transitions to the map. If the request fails, the user interactions object dispatches a SignInFailed containing the error message. The user interface can display the error message, hide the spinner and enable user interaction.

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
  }
}

Each action in the SignInActions is a struct that optionally contains data.

  • The SigningIn action only describes a new app state, but doesn’t need any extra data.

  • The SignedIn action changes the app state to “Signed In” and contains a UserSession object.

On its own, actions can’t change state in the store. They need to flow through a reducer function first. A reducer takes in the current state and an action and returns a new state.

Next, let’s check out the sign-in reducer:

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
  }

  // ...
}

The sign-in reducer takes an action and the current SignInViewControllerState and returns a new SignInViewControllerState. If no SignInViewControllerState is present, the reducer uses a default state.

For the SigningIn action, the reducer modifies variables on the SignInViewControllerState to disable user interaction and set the signInActivityIndicatorAnimating value to true.

Once the reducer returns, store updates its state. Then, the subscription in the sign-in view controller fires and the user interface updates.

The circle of Redux is complete! You’ve seen how a state change starts as an action, gets dispatched to the store and flows through a reducer to complete the update.

Next, you’ll learn how views communicate with each other using the Redux store.

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.

View controllers in a Redux architecture are naturally slim and focused. They fire actions and then forget about them, and may or may not care about how those actions affect the app’s state.

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.

It also transitions between multiple states when you are requesting a Koober:

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

For this example, let’s focus on the initial and selectDropoffLocation state.

Initially, the map displays a Where To? button and a preset pick-up location. Tapping the button brings up the drop-off location-picker screen, which loads a list of possible locations to visit in a Koober. After you select a location, the picker closes, and the map displays the selected location.

Going over how the map displays the picker when you press the button:

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

  // ...
}

PickMeUpViewController gets injected with a few dependencies:

  • PickMeUpViewControllerState publisher fires each time the state changes.

  • PickMeUpUserInteractions handles the Where To? button tap.

  • PickMeUpViewControllerFactory creates a DropoffLocationPickerViewController on demand.

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.
  // ...
}

The PickMeUpViewControllerState has data the view controller needs to display its user interface. Each time the state updates, PickMeUpViewController maps the PickMeUpState to PickMeUpView state, which is easier to consume.

The presentDropoffLocationPicker() method creates a DropoffLocationPickerViewController with all its dependencies and it presents the screen modally:

// ...

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

  present(viewController, animated: true)
}

// ...

This method gets called when the PickMeUpView state changes from initial to selectDropoffLocation. The state transition starts in the pick-me-up root view.

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()
  }

  // ...
}

PickMeUpViewController initializes PickMeUpRootView with its PickMeUpUserInteractions dependency.

The root view makes a call to the user interactions object when the user taps the Where To? button.

The concrete implementation of PickMeUpUserInteractions here is ReduxPickMeUpUserInteractions:

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 {}

  // ...
}

goToDropoffLocationPicker() dispatches a GoToDropoffLocationPicker action that tells the map to transition to the drop-off location picker.

Next, the action needs to flow through a reducer to update the store.

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
   }
}

The reducer handles the GoToDropoffLocationPicker action by creating an initial DropoffLocationPickerViewControllerState with the user’s current pick-up location, which is already in the store. Then, it updates the store state to .selectDropoffLocation containing the initial location picker state.

The last step in the process is for the PickMeUpViewController to handle the state update:

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.
    // ...
    }
  }
  // ...
}

PickMeUpViewController observes the PickMeUpViewControllerState and calls present(_:) each time the state changes. When the state changes to .selectDropoffLocation, the drop-off location picker screen is presented.

Note: The mapToView function converts the PickMeUpViewControllerState to a PickMeUpView state. If you’re interested in how that works check out the full implementation in Koober_iOS/iOSApp/SignedIn/PickMeUp/PickMeUpViewController.swift.

Here’s a diagram of the select drop-off location flow:

Let’s review the steps one by one:

  1. The pick-me-up view controller creates a root view with a user-interactions object.
  2. The pick-me-up root view calls goToDropoffLocationPicker() on the user-interactions object when the user taps the Where To? button.
  3. The user interactions dispatches a GoToDropoffLocationPicker to the store.
  4. The store runs the action through a reducer, which switches the state to .selectDropoffLocation.
  5. The store notifies the Combine subscription that the state changed.
  6. The pick-me-up view controller presents the select drop-off location picker screen.

The view layer reacts to state change and the tells the user-interactions object when the user interaction happened. The view only presents or dismisses screens when the store updates its state.

Selecting a ride option

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

  • Wallabies are tiny but cheap, and you’ll get to your destination with extra cash in hand!

  • Wallaroos are reliable, and they get you to your destination on time, every time.

  • Kangaroos are luxurious, and you’ll experience maximum hop distance on an adventurous ride.

In the previous pick-me-up screen example, you saw the state transition from .initial to .selectDropoffLocation. After the user selects a drop-off location, the state transitions to .selectRideOption:

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()
  }

  // ...
}

The pick-me-up view controller transitions to the next screen in present(_:). For the .selectRideOption state, it calls dropoffLocationSelected() to dismiss the drop-off location picker and to present the ride-option picker.

The logic to select a ride option lives in RideOptionPickerViewController:

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

  // ...
}

RideOptionPickerViewController gets injected with a couple dependencies:

  • The ImageCache caches the ride option button images.

  • The RideOptionPickerViewControllerState publisher fires when the state changes and contains data to display the ride options and errors to present.

  • The RideOptionPickerUserInteractions handles ride-option selections.

The view controller creates its RideOptionSegmentedControl root view with its user interactions object:

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
  }

  // ...
}

The segmented control renders the ride options segments using the RideOptionSegmentedControlState.

Each ride-option button in the segmented control is created using RideOptionSegmentState, which has a ride-option ID, along with some other meta data.

The view controller state and the root view have their own states. Giving the root view a more granular state helps keep them focused on user-interface specific state.

If you’d like to read through the entire ride option segment creation process, the full code is at Koober_iOS/iOSApp/SignedIn/PickMeUp/SelectRideOption/RideOptionSegmentedControl.swift.

When you tap a ride option, the user-interactions object handles selecting a new ride option ID.

public protocol RideOptionPickerUserInteractions {

  func loadRideOptions(
    availableAt pickupLocation: Location,
    screenScale: CGFloat)
  func select(rideOptionID: RideOptionID)
  func finishedPresenting(_ errorMessage: ErrorMessage)
}

The implementation in the RideOptionSegmentedControl is a ReduxRideOptionPickerUserInteractions:

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
  }
 
  // ...
}

The select(rideOptionID:) method dispatches a RideOptionSelected action that contains the ride option ID. This method gets called by the segmented control each time the user taps a new ride option.

After the user interactions object dispatches the action, a reducer handles the state change:

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
  }
}

The rideOptionPickerReducer takes in an action along with the current RideOptionPickerViewControllerState.

For the RideOptionSelected action, the reducer finds the segment matching the action’s rideOptionID, and it sets its isSelected flag to true. Then, it updates the state’s list of ride-option segments.

Next, the store notifies the subscribers of the updated state, which causes the AnyPublisher<RideOptionPickerViewControllerState, Never>’s subscription in the RideOptionPickerViewController to fire:

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)
    // ...
  }
  // ...
}

Each time the RideOptionPickerViewControllerState changes, the state gets mapped to a view specific RideOptionSegmentedControlState, and passed to the RideOptionSegmentedControl.

Updating the viewState variable causes the segments to re-render, and the segmented control highlights the selected ride option segment.

Here’s a diagram of the select ride option flow:

Let’s review each step one by one:

  1. Ride-option picker view controller creates a root view with a user interactions object.
  2. Ride-option segmented control calls select(rideOptionID:) on the user interactions object when the user taps a ride-option segment.
  3. The user-interactions object dispatches a RideOptionSelected action with the selected ride-option ID.
  4. The store runs the action through a reducer, which updates the isSelected Boolean on each of the ride option segments.
  5. The store notifies the Combine subscription that the state changed.
  6. The ride option picker view controller re-renders the segmented control to show the new ride-option selection.

That’s it! The ride-option segment view signals out to the store when a new ride option is selected. The view waits for the store to finish updating its state and then re-renders.

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

  • Redux architecture keeps all your app’s state in a single store.

  • An action describes a state change. The only way to change state is to dispatch an action to the store.

  • Reducers are pure functions that take an action and the current state, and they return a modified state. The only place the state can change is in a reducer function.

  • Convert your store subscriptions into Combine publishers using the publisher methods found in ReSwiftStorePublisher.swift. Then, you can abstract the ReSwift store from your view layer.

  • Focus your store subscriptions on pieces of the whole state using the select() method so Combine publisher subscriptions fire only when they need to.

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.

Here’s a few places to look:

  1. Follow the path for making the new ride request in the pick-me-up screen. After selecting a ride option, and confirming the ride request, the app transitions from the sendingRideRequest(NewRideRequest) state to .final in the PickMeUpViewController.

    Start in Koober_iOS/iOSApp/SignedIn/PickMeUp/PickMeUpViewController.swift for the user interface transitions.

    Then, explore how the reducer handles the ConfirmedNewRideRequest and NewRideRequestSent actions in KooberKit/UILayer/Features/Running/Features/SignedIn/Features/PickMeUp/Redux/PickMeUpReducer.swift.

  2. The Koober app prints out information about every dispatched action in the console.

    Check out how that’s done in KooberKit/Reusable/ReSwiftDiagnostics/ActionPrinterMiddleware.swift. It’s used to set up the store in Koober_iOS/iOSApp/KooberAppDepedencyContainer.swift.

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.
© 2026 Kodeco Inc.