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.
Sign up/Sign in
With a free Kodeco account you can download source code, track your progress, bookmark, personalise your learner profile and more!
Create accountAlready a member of Kodeco? Sign in
Sign up/Sign in
With a free Kodeco account you can download source code, track your progress, bookmark, personalise your learner profile and more!
Create accountAlready a member of Kodeco? Sign in
Contents
Getting a Redux Vibe Into SwiftUI
30 mins
- Getting Started
- Exploring Three Ducks
- SwiftUI, Meet Redux
- Creating the App State
- Creating the Store
- Observing State Changes
- Creating Actions
- Making a Reducer
- Dispatching Actions
- Putting Your Reducer to Work
- Vibing out to Redux
- Flipping the Cards
- Adding Your First Middleware
- Unflipping Cards
- Adding Middleware to the Store
- Winning the Game
- Where to Go From Here?
- Game Difficulty
- High Score
- Quack!
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.
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.
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:
- First, you create a mutable copy of the state value, so it can be updated.
- Pat yourself on the back for using an enumeration for actions. As you add more actions, this switch statement will grow.
- If the action is
.startGame
, you change thegameState
value to.started
. - 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.
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.
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.
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!