Software Architecture

Redux Architecture adaption in SwiftUI

Anurag Ajwani
6 min readJun 12, 2023
Photo by Markus Spiske on Unsplash

Building a SwiftUI app? Asking yourself how to organise your code? Software architecture are design patterns or templates-like that you can apply on how to design and organise your code.

Good software architecture can be decided on several attributes, for example:

  • Easier development by removing design decisions on feature-per-feature basis
  • Easier maintainability by simplifying the amount of effort required to understand the system
  • Ensuring reliability by making it easier to test the code

The list above is a non-exhaustive. How much you weight different attributes can be based on the problem you are trying to solve, the speed at which you want to be able to work at, the organisation structure that you work for and the complexity of adapting to the new architecture.

In this post I will introduce you to the Redux Architecture adaption for SwiftUI; or a version of it. Originally Redux was introduced for React.js applications on the Web. However SwiftUI shares some similar design with React. Both SwiftUI components and React components are re-rendered when state changes for example. Thus Redux Architecture can potentially adapt well to SwiftUI-based applications.

In this post we’ll first cover what Redux Principles are followed by how to apply Redux principles in SwiftUI. Here is an overview of the sections of this post:

  1. Redux Principles
  2. Redux in SwiftUI

For this post I assume you are comfortable with Swift, SwiftUI, Concurrency and Combine framework.

Redux Principles

There are three fundamental principles that drive the design of the Redux Architecture:

  1. Single source of truth
  2. State is read-only
  3. Changes are made with pure functions

We’ll dive into each one of them with simple examples to visualise each individual principle.

1. Single Source of Truth

The global state of your application is stored in an object tree within a single store.

- redux.js.org

In simple this means that all of the state is stored within a single place; named as the “store”. Here is an example for a Todo app:

class AppStore: ObservableObject {
@Published private(set) var state: AppState

init(initialState: AppState) {
self.state = initialState
}
}

struct AppState {
var todos: [Todo]
}

struct Todo {
var text: String
var completed: Bool
}

We have a store AppStore that holds the application state AppState. All of the application state is contained within the AppState.

Notice the AppStore property state’s access modifier is private(set). Why? We’ll cover this in the next principle.

2. State is read-only

The state is read-only. In the previous example the owner of AppStore instance can only read the state; the access modifier was private(set) for the state property. Then how can we modify state?

The only way to change the state is to emit an action, an object describing what happened.

- redux.js.org

A user may mark a to-do item as completed. When the user performs this operation an action has been performed.

enum Action {
case didSelectItem(Todo)
}

class AppStore: ObservableObject {
@Published private(set) var state: AppState

init(initialState: AppState) {
self.state = initialState
}

func dispatch(action: Action) {
...
}
}

Actions are not just user actions. The system can perform actions too. If our app required to fetch the todo items from a web server when the view is loaded or appears then we could have a getTodos action:

enum Action {
case getTodos
case didSelectItem(Todo)
}

Actions describes what happened. However these actions are not actually modifying the state. So how do we actually modify the state? That we’ll cover in the next principle.

3. Changes are made with pure function

What are “pure” functions? Let’s take a light recap. There are two conditions for a function to be “pure”:

  1. The output of a pure function depends solely on its input arguments. Given the same input, a pure function will always produce the same output.
  2. A pure function does not affect or depend on any external state or data outside of its input arguments.

Changes to the state in Redux can only be performed with “pure” functions. These are known as Reducers in Redux.

To specify how the state tree is transformed by actions, you write pure reducers.

To generate new state based on an action we need two things:

  1. The current state
  2. The action performed

Based on those two inputs we can then generate a new app state.

typealias Reducer = (_ state: AppState, _ action: Action) -> AppState

Let’s take a look of how an Todo app Reducer may look like:

func appReducer(state: AppState, action: Action) -> AppState {
var mutatingState = state
switch action {
case .didSelectItem(let item):
var mutatingItem = item
mutatingItem.completed.toggle()
let index = mutatingState.todos.firstIndex(where: { $0 == item })!
var items = mutatingState.todos
items[index] = mutatingItem
mutatingState.todos = items
}
return mutatingState
}

In the code above when the reducer receives the didSelectItem action it will look for the item within the state, mark it as completed if the item was uncompleted or vice-versa. Then the reducer will set this modified list as the new state.

Redux in SwiftUI

Now that we have an understanding of the Redux Architecture principles here is an overview of the components covered so far and their interactions:

So far all of the code in our Todo app has executed on the main thread and synchronously. What if we need to get the list of Todos items from a web server? We need to make an asynchronous call which does not change the app state straight away; only when we get a response. We can’t make these calls within a Reducer as it is a “pure” function.

Redux makes use of Middlewares to perform asynchronous calls or execute logic that later dispatches a new action. This new action can then be processed by the Reducer. Subsequently based on the new action the Reducer may then modify the state.

typealias Middleware = (_ state: AppState, action: Action) -> AnyPublisher<Action, Never>?

Let’s take a look at a concrete Middleware example:

enum Action {
case getTodos
case didReceiveItems([Todo])
case didSelectItem(Todo)
}

func appReducer(state: AppState, action: Action) -> AppState {
var mutatingState = state
switch action {
case getTodos:
break // do nothing
case didReceiveItems(let items):
mutatingState.items = items
case .didSelectItem(let item):
var mutatingItem = item
mutatingItem.completed.toggle()
let index = mutatingState.todos.firstIndex(where: { $0 == item })!
var items = mutatingState.todos
items[index] = mutatingItem
mutatingState.todos = items
}
return mutatingState
}

func fetchTodosMiddleware(state: AppState, action: Action) -> AnyPublisher<Action, Never>? {
guard Action.getTodos == action else { return nil }
// Fetch todos
// Stub response below
let items = [Todo(text: "Learn about Redux Architecture in SwiftUI", completed: false)]
return Just(.didReceiveItems(items))
.eraseToAnyPublisher()
}

class AppStore {
@Published private(set) var state: AppState

private let reducer: Reducer
private let middlewares: [Middleware]

private var subscriptions: Set<AnyCancellable> = []

init(initialState: AppState,
reducer: @escaping Reducer,
middlewares: [Middleware]) {
self.state = initialState
self.reducer = reducer
self.middlewares = middlewares
}

func dispatch(action: Action) {
for middleware in middlewares {
let publisher = middleware(self.state, action)
publisher
.receive(on: DispatchQueue.main)
.sink(receiveValue: dispatch)
.store(in: &subscriptions)
}
self.state = self.reducer(self.state, action)
}
}

Middlewares are intended to intercept actions and execute upon these actions before the state may be modified by the Reducer.

Here is the complete overview of Redux adaption in SwiftUI:

With Middlewares we complete the picture of Redux in SwiftUI.

Conclusion

The Redux Architecture helps us identify where code should go. Once the concepts are digested it can help speed development by reducing design and decision making from implementing features. It can help with understanding how the system works as a whole. Furthermore Redux concepts fit quite nicely with SwiftUI.

We didn’t cover testing with examples in this post. However knowing where each piece of code goes before even writing a single line of code can help when driving implementation using Test Driven Development (TDD).

However there is no silver bullet to solving problems in software design. Each will have its own advantages and disadvantages. It is important that these are weighed against the problem that you are solving.

--

--

Anurag Ajwani

Senior iOS Engineer at Travelperk. 7+ years experience with iOS and Swift. Blogging my knowledge and experience.