Getting a Redux Vibe Into SwiftUI

Learn how to implement Redux concepts to manage the state of your SwiftUI app in a more predictable way by implementing a matching-pairs card game. By Andrew Tetlaw.

4.7 (19) · 2 Reviews

Download materials
Save for later
Share
You are currently viewing page 2 of 4 of this article. Click here to view the first page.

Creating Actions

Your next task is to update gameState when the player taps New Game on the title screen. This requires the next major Redux component: an action.

Anything can be an action, as long as it has an identity and room for some extra properties, if required. In Swift, an enumeration is the perfect solution. So many problems can be solved with Swift enumerations, they’re like a superpower.

Swifty as a superhero

Add a new file under the State group named Actions.swift. Then, create a new enumeration called ThreeDucksAction with a case named startGame:

enum ThreeDucksAction {
  case startGame
}

You’ll use the startGame action to start the game.

Your store needs to know what type of actions it should manage. Find Store.swift, and update Store so it matches:

class Store<State, Action>: ObservableObject {

With this change, you require callers of the store to provide both the current state and an action they wish to perform.

You’ll also need to update ThreeDucksStore to include your new action type:

typealias ThreeDucksStore = Store<ThreeDucksState, ThreeDucksAction>

The type-alias ThreeDucksStore now corresponds to a specific store that manages both ThreeDucksState and ThreeDucksAction.

Making a Reducer

Your store doesn’t do anything yet. This is where you’ll add a reducer. You can think of it as a function that returns a new state based on the current state and a given action.

Make it pure: In theory, anything can be a reducer, but if you want to match the vibe of Redux, you’ll make it a pure function. If you haven’t heard that term before, the simplest definition is that a pure function is 100% predictable: It always returns the same output for the same input, it has no state, no side effects, no randomness and no calls to external functions.

You’ll implement reducers as Swift closures. Including all the necessary logic within the closure will be a challenge, but worthwhile to your store code, as you’ll see.

Create a new file called Reducer.swift under the State group and add the following typealias:

typealias Reducer<State, Action> = (State, Action) -> State

You’ve just defined a closure that takes two arguments, one of type Action and one of type State, and returns a State value. Next, create the reducer for Three Ducks:

let threeDucksReducer: Reducer<ThreeDucksState, ThreeDucksAction> 
  = { state, action in
    return state
}

Your reducer just returns the state value it receives, for now. Next, open Store.swift and add the following to Store, below state:

private let reducer: Reducer<State, Action>

You’ll also need to update init(initial:) to include this new property:

init(
  initial: State,
  reducer: @escaping Reducer<State, Action>
) {
  self.state = initial
  self.reducer = reducer
}

Congratulations! Your store is now able to apply actions to a state. Finally, you’ll need to update the code in AppMain and ContentView_Previews where you created an environment object. Open AppMain.swift and ContentView.swift and update the line where you create ThreeDucksStore to match:

.environmentObject(ThreeDucksStore(
  initial: ThreeDucksState(), 
  reducer: threeDucksReducer))

Dispatching Actions

You already have a lot of the pieces of your store put together. You just need to connect them all by adding a way for you to ask the store to execute an action. Traditionally, this is known as the dispatch function. To maintain the Redux vibe, you need to make sure the only way to update the state is to call the dispatch function of your store and pass an action to it.

First, open Store.swift and add the following under the reducer property in Store:

private let queue = DispatchQueue(
  label: "com.raywenderlich.ThreeDucks.store",
  qos: .userInitiated)

Note that it’s a serial queue and the quality of service is set to .userInitiated. Next, add dispatch(_:) at the end:

func dispatch(_ action: Action) {
  queue.sync {
    // ...
  }
}

This method accepts an action and submits a closure to your queue synchronously. This ensures that the actions are executed in the order they arrive and that the state is up to date for each action when it’s dispatched. The actual work you’ll perform is in a private method you add next. Add the following below dispatch(_:):

private func dispatch(_ currentState: State, _ action: Action) {
  let newState = reducer(currentState, action)
  state = newState
}

This private method takes a state and an action, passes both to the reducer and accepts the returned state value. Finally, it updates the store’s state property with the new state.

Finally, add a call to the private method from inside the queue.sync closure in dispatch(_:):

self.dispatch(self.state, action)

Putting Your Reducer to Work

Now, turn your attention to the reducer, because that’s where the magic happens. Replace the body of the reducer closure in Reducer.swift with the following:

// 1
var mutatingState = state
// 2
switch action {
case .startGame:
  // 3
  mutatingState.gameState = .started
}
// 4
return mutatingState

Here’s what’s happening:

  1. First, you create a mutable copy of the state value, so it can be updated.
  2. Pat yourself on the back for using an enumeration for actions. As you add more actions, this switch statement will grow.
  3. If the action is .startGame, you change the gameState value to .started.
  4. The last job is to return the new state.

Now, you’ll wire up that New Game button. Open TitleScreenView.swift and add the store environment object before body:

@EnvironmentObject var store: ThreeDucksStore

Next, find the button for New Game and add this as the action:

store.dispatch(.startGame)

Build and run your app. It will switch to the game screen when you tap the button.

Three Ducks state the game.

Now that you’re on the game screen, there’s no way to go back. The Give Up button does nothing. You’ll fix that next. Open Actions.swift and add a new action:

case endGame

Then, open Reducer.swift and add another case:

case .endGame:
  mutatingState.gameState = .title

Next, open GameScreenView.swift and add the ThreeDucksStore environment object before body:

@EnvironmentObject var store: ThreeDucksStore

Then, add the dispatch call in the button action in the body:

store.dispatch(.endGame)

Build and run the app. Just like that, you’ve created a transition between two screens in both directions.

Three Ducks state the game.

The diagram below shows the flow of action from app views, when the user taps New Game, to Reducer where the state changes and causes an update in app views.

Chart of app function

Notice there’s no animation when you switch between screens. But that’s easy to fix in SwiftUI. Open TitleScreenView.swift and replace the call to store.dispatch(.startGame) with:

withAnimation {
  store.dispatch(.startGame)
}

Next, open GameScreenView.swift and replace store.dispatch(.endGame) with:

withAnimation {
  store.dispatch(.endGame)
}

Build and run one more time and notice the cross-fade animation when switching between screens.

Take a moment to appreciate what you’ve just achieved. Usually, state management code feels like soggy bread held together with duct tape. Instead, you’ve implemented an architectural marvel, created a single source of truth and disturbed the view code very little. Don’t stop now!