Published on

How to build a Redux-like container in Swift from scratch

Authors

In this article, we'll take a small break from the SwiftUI for UIKit developers series and have a look at Redux.

Redux is a JavaScript library for predictably managing state. Inspired by Facebook's Flux architecture, Redux gained popularity in the last decade and become the de facto state management solution for React apps.

Alright, but why do we care? Well, it turns out Redux architecture match SwiftUI's paradigm well. TCA is the most popular solution leveraging this, but while being a great library overall and probably the perfect pick for some teams, it also has some drawbacks:

  1. It's a third-party dependency.
  2. Adds a layer of substantial complexity to a rather simple concept.
  3. It's opinionated about how SwiftUI should consume and produce state updates, offering an array of tools to do so.
  4. It has a rather steep learning curve which leads to ambiguity until mastered.
  5. It does not use Swift Concurrency for side effects. While this is minor and can be easily mitigated, it's always nice to have it out of the box

To add to it, some of the articles discussing TCA in detail are behind a subscription paywall. I'd certainly recommend subscribing, the content is top-notch, however, this might not be an option for everyone.

Alright, so what are we going to build? We're going to build the simplest possible, ready-for-production Redux container in Swift. It should:

  1. Allow for reducer (and state) composition.
  2. Support action dispatch from any thread.
  3. Be completely hidden from the views, allowing SwiftUI to use its own state propagation mechanism.
  4. Support Swift Concurrency for side effects.
  5. Be completely agnostic in all other matters.
Subscribe and get articles like this in your email!
Before we start

This article's code examples have an incremental approach to better show all the required steps in building our store and the problems we're facing along the way. The examples are not production-ready, and if you plan to write a store yourself, or want to have a look at the final version, check out this repo.

The reducer

Arguably the most important part, the reducer is a function that takes the state and an action and outputs the new state, updated based on the action. A generic reducer would look like this:

protocol Action {}
typealias Reducer<S, A> = (inout S, A) -> Void where A: Action

Actions are usually defined as enums and the state is a plain struct:

enum NavigationAction: Action {
    case present(path: String)
    case dismiss
}

struct NavigationState {
    var history: [String]
}

With that in mind, a naive navigation reducer would be:

let navReducer: Reducer<NavigationState, NavigationAction> = { state, action in
    switch action {
    case let .present(path):
        state.history.append(path)
    case .dismiss:
        if state.history.count > 0 {
            state.history.removeLast()
        }
    }
}

The store

Alright, that's a good start. We have a function that mutates the state based on a set of predefined actions. We could use this as it is, but it would require passing around references to both the state and the reducer. A better approach would be to wrap it all into a class responsible for managing the state internally. We'll call it a store:

class Store<S, A> where A: Action {
    private var _state: S
    private var _reducer: Reducer<S, A>

    init(initialState: S, reducer: Reducer<S, A>) {
        _state = initialState
        _reducer = reducer
    }

    func dispatch(action: A) {
        _reducer(&state, action)
    }
}

We're heading in the right direction, but there are quite a few problems left unsolved. First, after we dispatch the action and apply the reducer, there's no way to read the new state.

It can be solved in multiple ways. We could, for example, make the _state public and mark it @Published while also making the Store an ObservableObject. However, this would mean the views would need to keep a reference to the Store and that's something we'd rather avoid.

This is one of the things that, for me, never feel right about TCA. SwiftUI was designed for MVVM and thrives using it. Any architecture that breaks the basic relationship between the entity that prepares the domain data (view model) and the view, is fighting SwiftUI, won't feel natural, and most probably will overcomplicate things.

Instead, we'll allow interested view models to register for specific state updates, giving them the chance to prepare data for the view before displaying. To do so, we need to introduce a Combine publisher that fires each time state changes:

class Store<S, A> where A: Action {
    private var _state: S
    private var _reducer: Reducer<S, A>
    private let _statePub: PassthroughSubject<S, Never>

    // ...
}

For the next step, we'll use key paths to provide interested parties only the sub-state value they require and nothing more. We also make sure the value is Equatable to avoid sending the same value multiple times:

func watch<V>(_ keyPath: KeyPath<S, V>) -> AnyPublisher<V, Never>
    where V: Equatable
{
    _statePub
        .prepend(state)
        .map { $0[keyPath: keyPath] }
        .removeDuplicates()
        .receive(on: RunLoop.main)
        .eraseToAnyPublisher()
}

This maps part of our state (or the whole state) with a published property. A view model would use this like so:

class DashboardViewModel: ObservableObject {
    @Published var currentPath: String? = nil
}

extension DashboardViewModel {
    convenience init(store: MainStore) {
        self.init()
        store.watch(\.navigation.history.last)
            .assign(to: &$currentPath)
    }
}

This way the view model holds a reference to the store and can react to state changes without involving the view.

Next up we'll solve two problems in one go.

First, the current store supports only a single reducer. That doesn't scale well. In fact, it scales terribly, bloated reducers being one of the most annoying things in Redux architectures.

Second, some reducers might not be able to operate on the entire state, only on part of it. They might be provided by modules that are unaware of entities stored in other parts of the state. For example, it's sensible to assume the navigation reducer lives in a module that doesn't necessarily have access to the repository used to communicate with the backend.

To solve this we introduce MappedReducer, an action type eraser that also doubles as a state mapper, providing the enclosed reducer only the part of the state it's interested in:

struct MappedReducer<S> {
    private let _reducer: Reducer<S, AnyAction>

    init<S1, A>(state stateKeyPath: WritableKeyPath<S, S1>,
                reducer: @escaping Reducer<S1, A>) where A: Action {
        _reducer = { state, action in
            if let typedAction = action.base as? A {
                reducer(&state[keyPath: stateKeyPath],
                        typedAction)
            }
        }
    }

    func callAsFunction(_ state: inout S,
                        _ action: any Action) -> Void {
        return _reducer(&state, AnyAction(action))
    }
}

private struct AnyAction: Action {
    let base: Any
    init(_ action: any Action) {
        base = action
    }
}

Now we can add multiple reducers to our store:

// We don't need a generic `A` parameter anymore
class Store<S> {
    private var _state: S
    private var _reducers: [MappedReducer<S>]
    private let _statePub: PassthroughSubject<S, Never>
    // ...

    func add<S1, A>(reducer: @escaping Reducer<S1, A>,
                    state stateKeyPath: WritableKeyPath<S, S1>) where A: Action {
        _reducers.append(
            MappedReducer(state: stateKeyPath,
                          reducer: reducer)
        )
    }

    // ...
}

// This would allow us to add a reducer
// that handles only part of the app state
// and it's unaware of the rest.
struct AppState {
    var navigation: NavigationState
    // ...
}

let store = Store(initialState: appState)
store.add(reducer: navReducer, state: \.navigation)

Ok. We're almost there. Two things remain: side effects and thread safety.

Side effects

Reducers are by definition pure functions. You should never have any side effects inside reducers. So, we'll externalize things like accessing the network by allowing the reducer to return an optional async function that we'll invoke at a later time:

typealias Dispatch = (any Action) -> Void
typealias SideEffect<E> = (E, @escaping Dispatch) async -> Void
typealias Reducer<S, A, E> = (inout S, A) -> SideEffect<E>? where A: Action

The side effect closure has two arguments: the first is the environment and the second is the dispatch function. The environment is scoped to the store and will hold every service required by the reducer to do its job. That could mean the network layer, the database cursor and generally any service the app uses. The dispatch closure is the store's dispatch(action:) and is used to feed actions into the store once the side effects are done.

Here's a reducer with side effects:

let bootstrapReducer: Reducer<AppState, AppAction, AppEnvironment> = { state, action in
    switch action {
    case .didBecomeActive:
        state.phase = .active
        state.navigation.history = ["/launching"]
        return { env, dispatch in
            // Inside the side effect we can `await`.
            if let user = await env.identity.fetchUser() {
                // Once we're done, we dispatch other actions.
                dispatch(IdentityAction.setUser(user))
                dispatch(NavigationAction.present("/dashboard"))
            }
            else {
                dispatch(NavigationAction.present("/gatekeeper"))
            }
        }
    }
}

To achieve this our store needs to change a bit. I recommend checking out the full repo here for details, but the main idea is: split the dispatch(action:) function in two: first collect all the side effects associated with an action, then concurrently perform all of them:

func dispatch(action: any Action) {
    let sideEffects = _dispatch(action: action)

    Task.detached { [weak self] in
        await self?._perform(sideEffects: sideEffects)
    }
}

func _dispatch(action: any Action) -> [SideEffect<E>] {
    var sideEffects: [SideEffect<E>] = []
    for reducer in _reducers {
        if let sideEffect = reducer(&_state, action) {
            sideEffects.append(sideEffect)
        }
    }

    return sideEffects
}

func _perform(sideEffects: [SideEffect<E>]) async {
    await withTaskGroup(of: Void.self) { [weak self] group in
        for sideEffect in sideEffects {
            group.addTask { [weak self] in
                // `self` refers to our store here
                if let strongSelf = self {
                    let dispatch: Dispatch = {
                        self?.dispatch(action: $0)
                    }
                    await sideEffect(strongSelf.environment, dispatch)
                }
            }
        }
    }
}

Thread safety

This is the last step. We'll use a private DispatchQueue to sync writes with a barrier while allowing all threads to read without taking a lock. For details, I'm going to once again link the example repo, but the crux of it is:

// Here we're protecting the `_state` member.
var state: S {
    get { _queue.sync { _state } }
    set { _queue.sync(flags: .barrier) { _state = newValue } }
}

Final word

I hope this was somewhat helpful and will encourage you to try and experiment with the Redux architecture in Swift: there are many reasons to do so, from testability and predictability to easy modularization.