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
Action
values. TheAction
can be any kind of type, but for convenience, can think ofAction
as 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
reducer
is a pure function (it calculates its output based only on its input, and generates no side effects), that based on the currentState
and an incomingAction
, generates a newState
. It is the ONLY part of the code where theState
is modified. - Whenever something happens in the
View
, it dispatches anAction
to theStore
. TheStore
executes thereducer
, and updates its internalState
. TheState
then is sent to theView
so it can update accordingly.
- However, we want to travel in time, to do so, we store all the
Action
sent to the store, and the initialState
. If we want to undo, we just remove the last element in theAction
array and compute theState
from 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
AppState
and theappReducer
. - It has to dispatch
Action
values. - It has to notify the
View
whenever theAppState
mutates. - 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@StateObject
inside 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
todoText
in thestore
. The setter dispatches anAction
whenever it changes. - The
store
is used todispatch
actions when things happen in theView
. - The
store
is also used to get the current state of the application. - The
undo
andredo
methods in thestore
are yet to be defined, but theView
is 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
Store
with all of theAction
that have been dispatched so far. Let's call thisappliedActions
. - Storing the initial
AppState
in a variable calledinitialState
so we can compute any subsequentState
by applying an array ofAction
values using thereducer
. - Storing an array in the
Store
with all of theAction
values that have been "undone", so we can "redo" them (by appending them again in theappliedActions
array). 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)