In the previous article, we have talked about how functional purity and an unidirectional architecture by using the Action-State-Reducer combo can give us incredible powers such as time traveling.
Quick Recap
These were the main points of the previous article:
- All events that happen in an application can be described using
Actionvalues. TheActioncan be any kind of type, but for convenience, can think ofActionas an enum. - The application state is stored in a struct or class named
State. - We define a
Store, which is an object that holds the applicationState, and a pure function calledreducer. - The
reduceris a pure function (it calculates its output based only on its input, and generates no side effects), that based on the currentStateand an incomingAction, generates a newState. It is the ONLY part of the code where theStateis modified. - Whenever something happens in the
View, it dispatches anActionto theStore. TheStoreexecutes thereducer, and updates its internalState. TheStatethen is sent to theViewso it can update accordingly.
- However, we want to travel in time, to do so, we store all the
Actionsent to the store, and the initialState. If we want to undo, we just remove the last element in theActionarray and compute theStatefrom the beginning. Something similar can be done to redo.
In code
Well, if you understood everything that was described in the last section, the code may look pretty straightforward to you.
Let's dive in.
The State
Well, this is maybe the simplest component to grasp. The State must hold all the application state we want to use. In our case, a simple Todo app, this can be something like this:
struct AppState {
let todos: [Todo]
let todoText: String
}
struct Todo: Identifiable {
let id: UUID
let title: String
let completed: Bool
}
For convienience, we will define a default or maybe initial state getter on our AppState
extension AppState {
static var `default`: AppState {
AppState(todos: [], todoText: "")
}
}
Nice, so let's leave the AppState as it is at this point.
The Action
We can define Action cases as an enum
enum Action {
case todoTextChange(String)
case createTodo(id: UUID)
case toggleTodo(id: UUID)
}
Nice. We can modify the text input to create a new todo, create the todo using the todoText string we have in our AppState, or toggle the completion of some todo.
There is something here that might look strange. Why are we sending the id for the todo we are creating? Well, if the reducer has to create the UUID itself, and if it uses the UUID initializer, we would get a different UUID every time we run the createTodo action. In such a case, the reducer IS NOT PURE.
The reducer
Ok, so we already have the AppState and the Action types. Let's go for the appReducer.
func appReducer(state: AppState, action: Action) -> AppState {
switch action {
case .todoTextChange(let newText):
// ...
case .createTodo(let id):
// ...
case .toggleTodo(let id):
// ...
}
}
So far, so good. We have our appReducer that takes the current AppState and an Action and returns a new AppState.
Let's see how it looks the todoTextChange:
func appReducer(state: AppState, action: Action) -> AppState {
switch action {
case .todoTextChange(let newText):
return AppState(
todos: state.todos,
todoText: newText
)
case .createTodo(let id):
// ...
case .toggleTodo(let id):
// ...
}
}
Pretty straightforward. If we need to change the todoText, we just create a new AppState, reusing the state.todos, because we don't want to mutate them.
func appReducer(state: AppState, action: Action) -> AppState {
switch action {
case .todoTextChange(let newText):
// ...
case .createTodo(let id):
let newTodo = Todo(
id: id,
title: state.todoText,
completed: false
)
var currentTodos = state.todos
currentTodos.insert(newTodo, at: 0)
return AppState(
todos: currentTodos,
todoText: ""
)
case .toggleTodo(let id):
// ...
}
}
For the createTodo action, we first create a new Todo using the id we send in the action, and return a new AppState with the old todos and the new Todo, and clear the todoText.
func appReducer(state: AppState, action: Action) -> AppState {
switch action {
case .todoTextChange(let newText):
// ...
case .createTodo(let id):
// ...
case .toggleTodo(let id):
var currentTodos = state.todos
let index = currentTodos.firstIndex(where: { $0.id == id })!
let todo = currentTodos[index]
currentTodos[index] = Todo(
id: id,
title: todo.title,
completed: !todo.completed
)
return AppState(
todos: currentTodos,
todoText: ""
)
}
}
And finally, for the toggleTodo we get the Todo with the id equal to the id we send in the Action, change its completed property, and finally send a new AppState.
So the complete reducer implementation is as follows:
func appReducer(state: AppState, action: Action) -> AppState {
switch action {
case .todoTextChange(let newText):
return AppState(
todos: state.todos,
todoText: newText
)
case .createTodo(let id):
let newTodo = Todo(
id: id,
title: state.todoText,
completed: false
)
var currentTodos = state.todos
currentTodos.insert(newTodo, at: 0)
return AppState(
todos: currentTodos,
todoText: ""
)
case .toggleTodo(let id):
var currentTodos = state.todos
let index = currentTodos.firstIndex(where: { $0.id == id })!
let todo = currentTodos[index]
currentTodos[index] = Todo(
id: id,
title: todo.title,
completed: !todo.completed
)
return AppState(
todos: currentTodos,
todoText: ""
)
}
}
The Store
Maybe the store is the most difficult component in this implementation. Lets review the responsibilities the store holds in this example:
- It has to hold the
AppStateand theappReducer. - It has to dispatch
Actionvalues. - It has to notify the
Viewwhenever theAppStatemutates. - It has to undo and redo, at any point.
Let's go one by one:
It has to hold the AppState and the appReducer.
For this one, we could just initialize the Store with those values.
final class Store {
private(set) var state: AppState
let reducer: (AppState, Action) -> AppState
init(
initialState: AppState,
reducer: @escaping (AppState, Action) -> AppState
) {
self.state = initialState
self.reducer = reducer
}
static var `default`: Store {
Store(
initialState: .default,
reducer: appReducer
)
}
}
We also added a default static getter so we can create the Store using the values we have just defined above.
It has to dispatch Action values.
What does this mean? Well, we need a dispatch function, so the view can send an Action when something happens.
final class Store {
// ...
func dispatch(_ action: Action) {
state = reducer(state, action)
}
// ...
}
For now, let's leave this as simple as this. Whenever the Store dispatches an Action, it just executes the reducer, passing the current State and the received Action, and stores the obtained State in its internal state.
It has to notify the View whenever the AppState mutates.
This will be used in SwiftUI in this example. How does an object notify the View that its internal state has changed? Exactly, conforming to ObservableObject, that is the simplest way of doing so, and we don't want to overcomplicate the things. Also, we need to make the state property, @Published.
final class Store: ObservableObject {
@Published private(set) var state: AppState
let reducer: (AppState, Action) -> AppState
init(
initialState: AppState,
reducer: @escaping (AppState, Action) -> AppState
) {
self.initialState = initialState
self.state = initialState
self.reducer = reducer
}
static var `default`: Store {
Store(
initialState: .default,
reducer: appReducer
)
}
func dispatch(_ action: Action) {
state = reducer(state, action)
}
}
This is how the Store looks so far. Nice.
This is the View:
import SwiftUI
struct CotentView: View {
// 1
@StateObject var store = Store.default
var body: some View {
VStack {
HStack {
TextField(
"Add to do",
// 2
text: Binding<String>(
get: { return store.state.todoText },
set: { newTodoText in store.dispatch(.todoTextChange(newTodoText)) }
)
)
Button("Create") {
// 3
store.dispatch(.createTodo(id: UUID()))
}
}
List {
// 4
ForEach(store.state.todos) { todo in
Text("\(todo.title) \(todo.completed ? "DONE!" : "")")
.padding()
.onTapGesture(count: 1, perform: {
// 3
store.dispatch(.toggleTodo(id: todo.id))
})
}
}
HStack {
Button("UNDO") {
// 5
store.undo()
}
Spacer()
Button("REDO") {
// 5
store.redo()
}
}
}
.padding()
}
}
This is not a tutorial of SwiftUI, but I want to note a couple of things here
- We are storing the
store, as a@StateObjectinside theView. - Bindings are a bit unusual when we are using a unidirectional architecture. In this case, we need to define a getter and a setter for it. The getter just returns the
todoTextin thestore. The setter dispatches anActionwhenever it changes. - The
storeis used todispatchactions when things happen in theView. - The
storeis also used to get the current state of the application. - The
undoandredomethods in thestoreare yet to be defined, but theViewis already set at this point.
It has to undo and redo, at any point.
The last point. This can be done by:
- Storing an array in the
Storewith all of theActionthat have been dispatched so far. Let's call thisappliedActions. - Storing the initial
AppStatein a variable calledinitialStateso we can compute any subsequentStateby applying an array ofActionvalues using thereducer. - Storing an array in the
Storewith all of theActionvalues that have been "undone", so we can "redo" them (by appending them again in theappliedActionsarray). Let's call this arrayundoneActions.
In code:
final class Store: ObservableObject {
private let initialState: AppState
private var appliedActions: [Action] = []
private var undoneActions: [Action] = []
@Published private(set) var state: AppState
let reducer: (AppState, Action) -> AppState
init(
initialState: AppState,
reducer: @escaping (AppState, Action) -> AppState
) {
self.initialState = initialState
self.state = initialState
self.reducer = reducer
}
static var `default`: Store {
Store(
initialState: .default,
reducer: appReducer
)
}
func dispatch(_ action: Action) {
appliedActions.append(action)
undoneActions = []
state = reducer(state, action)
}
func redo() {
state = initialState
if let undoneAction = undoneActions.popLast() {
appliedActions.append(undoneAction)
for action in appliedActions {
state = reducer(state: state, action: action)
}
}
}
func undo() {
state = initialState
if let undoneAction = appliedActions.popLast() {
undoneActions.append(undoneAction)
for action in appliedActions {
state = reducer(state: state, action: action)
}
}
}
}
That is the final implementation for the Store. Note that for the redo and undo we use the reducer to compute all the actions again, and we play with the appliedActions and undoneActions arrays to move Action values from one place to another, achieving in this way, time traveling in Swift.
Conclusion
To sum up, this has been a quick introduction to Redux (unidirectional architectures) in Swift, completely from scratch. There A LOT of missing pieces in this implementation, of course. We didn't cover testing, and we don't have any way of modeling side effects, like network requests. I highly recommend The Composable Architecture if you want to use this kind of architectures in production. As far as I have use this architecture, it worked fine to me. However, I have to say this is still early days in SwiftUI architectures and the landscape might change. My last piece of advice here is: remember Software Engineering is about taking decisions balancing tradeoffs. Keep curiosity but think everything carefully.

Top comments (0)