Aug 03, 2020

Redux-like architecture with SwiftUI: Side Effects

In the previous post we went through setting up the basic architecture for iOS with Redux-like approach, and now we will be following up with additional optimizations and support for asynchronous functions or side effects.

So far, our app is aligned with Redux three principles:

  • A single source of truth
  • A read-only App State
  • Mutating the state only through Reducer functions.

What happens if we want to load animal names from the network instead of a local array?

Sure, we could add all that logic and dependencies to our appReducer, and make it work for this small app, but it’s far from optimal and goes against Redux principles.

By definition, Reducers must be pure functions—functions that return the exact same output for given inputs, and should also be free of side effects like API calls or asynchronous tasks, so let’s introduce the Middleware concept.

This project is based on the code we wrote in the previous post. Before starting, grab a clean copy from this repository.

Middlewares

In Redux, a Middleware is an entity (function) that is executed when certain Actions go through the Store. It receives a copy of the current AppState, performs some operations (API calls, or async tasks), and dispatches a new Action.

Middlewares take care of all the heavy load in the background (like API call, database fetches and updates, etc…) while Reducers simple deal with updating our state synchronously. Let’s update the store to support Middlewares.

AppStore.swift

import Foundation
import Combine

typealias Middleware<State, Action> = (State, Action) -> AnyPublisher<Action, Never>?
typealias AppStore = Store<AppState, AppAction>

final class Store<State, Action>: ObservableObject {

    // Read only access to app state
    @Published private(set) var state: State

    var tasks = [AnyCancellable]()

    let serialQueue = DispatchQueue(label: "redux.serial.queue")

    private let reducer: Reducer<State, Action>
    let middlewares: [Middleware<State, Action>]
    private var middlewareCancellables: Set<AnyCancellable> = []

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

    // The dispatch function.
    func dispatch(_ action: Action) {
        reducer(&state, action)

        // Dispatch all middleware functions        
        for mw in middlewares {
            guard let middleware = mw(state, action) else {
                break
            }
            middleware
                .receive(on: DispatchQueue.main)
                .sink(receiveValue: dispatch)
                .store(in: &middlewareCancellables)
        }
    }
}

I’ve created a new typealias to define our Middleware, and then added a Middleware array to our Store. This allows us to inject as many Middleware functions as we need on initialization.

Then, I’ve modified our dispatch function to iterate on the available Middlewares. Each Middleware will receive a copy of our State and an Action, and will return a Combine Publisher containing a new Action, which will be dispatched upon reception.

So now, instead of generating animal names in our reducer, let’s go asynchronous and create a Service that simulates a network call, by generating an animal name after X(random) number of seconds.

AnimalService.swift

import Foundation
import Combine

struct AnimalService {

    func generateAnimalInTheFuture() -> AnyPublisher<String, Never> {
        let animals = ["Cat", "Dog", "Crow", "Horse", "Iguana", "Cow", "Racoon"]
        let number = Double.random(in: 0..<5)
        return Future<String, Never> { promise in
            DispatchQueue.main.asyncAfter(deadline: .now() + number) {
                let result = animals.randomElement() ?? ""
                promise(.success(result))
            }
        }
        .eraseToAnyPublisher()
    }

}

Here we are using the Combine “Future Publisher” to create a promise, that is fulfilled after a random number of seconds with a random name. As you can see, we are returning a publisher of type AnyPublisher and not a String. On completion, the Future publisher produces a single String value, and then finishes or fails. In this case, it never fails, so we set it up as <String, Never>

By using AnyPublisher, instead of Publisher we wrap our publisher, so its details are not exposed, and also prevent its callers from accessing send(:) on it. If you want to find out more about this, check out this Stack Overflow thread.

Now that the Store is ready for async tasks, and we have our new service, let’s move forward with optimizing our Redux Stack and implementing our first Middleware.

Let’s do some Optimizations

We could store everything in our AppState struct, and that works for a small app like this one, but as apps grow you end up passing a huge amount of data around and things get out of hand.

AppState.swift

import Foundation

struct AppState {
    var animal: AnimalState
}

struct AnimalState {
    var currentAnimal: String = ""
}

Here we are simply dividing our state into different pieces and composing them in AppState. You can continue to add as many structs as needed to keep things clean and easy to understand.

Now let’s setup the Actions for the animal generator. Since now we are fetching the data asynchronously, we’ll need two: One that performs the fetch, and another that sets the result value in our State when the data arrives.

As we did with our State, let’s do some composition here too.

AppActions.swift

import Foundation

enum AppAction {
    case animal(action: AnimalAction)
}

enum AnimalAction {
    case fetchAnimal
    case setCurrentAnimal(animal: String)
}

Following the same pattern, we can also extract the logic for our reducers.

Reducers.swift

import Foundation

typealias Reducer<State, Action> = (inout State, Action) -> Void

func appReducer(state: inout AppState, action: AppAction) -> Void {
    switch(action) {

    case .animal(let action):
        animalReducer(state: &state.animal, action: action)
    }

}

func animalReducer(state: inout AnimalState, action: AnimalAction) -> Void {
    switch(action) {
        case .fetchAnimal:
            break
        case .setCurrentAnimal(let animal):
            state.currentAnimal = animal
    }
}

With those changes in place we will keep things tidy and clean, when the app grows, and limit the data we pass around. The app won’t compile as we have not updated our views, but let’s move onto the Middleware and we can update that later.

This is also a good time to reorganize our functions and Types into different files and groups. Here’s an example that would work for most small to medium size apps.

Implementing our Middleware

The animalMiddleware will receive a copy of the current State and an Action and returns a Publisher. We will be using the new service we’ve created, to fetch an animal, and generate a new Action, that will be sent back for dispatch.

AnimalMiddleware.swift

import Foundation
import Combine

func animalMiddleware(service: AnimalService) -> Middleware<AppState, AppAction> {

    return { state, action in
        switch action {

            case .animal(.fetchAnimal):
                return service.generateAnimalInTheFuture()
                    .subscribe(on: DispatchQueue.main)
                    .map { AppAction.animal(action: .setCurrentAnimal(animal: $0 )) }
                    .eraseToAnyPublisher()

            default:
                break
            }

        return Empty().eraseToAnyPublisher()
    }
}

Pretty neat, huh?. Since we are using Combine, there is no need for callbacks or weird tricks to handle asynchronous operations and as our application grows, we can add more cases to our Middleware to intercept other actions.

It also helps map over the resulting data, and dynamically generate an Action to be dispatched.

Now let’s modify our Store initializer to inject our Middleware as a dependency.

ContentView.swift

import SwiftUI

struct ContentView: View {
    let store = AppStore(initialState:
                            .init(animal: AnimalState()),
                      reducer: appReducer,
                      middlewares: [
                        animalMiddleware(service: AnimalService())
                      ])


    init() {
        store.dispatch(.animal(action: .fetchAnimal))
    }

    var body: some View {
        AnimalView()
            .environmentObject(store)
    }
}

struct ContentView_Previews: PreviewProvider {
    static var previews: some View {
        ContentView()
    }
}

In essence, we are injecting our new AnimalState and the Middleware in the initializer. This approach makes our code really easy to test

We’ve also changed the dispatch call in our initializer to match the changes we did to our Store.

Now, let’s take care of updating the view.

AnimalView.swift

import SwiftUI

struct AnimalView: View {
    @EnvironmentObject var store: AppStore

    func loadAnimal() {
        store.dispatch(.animal(action: .fetchAnimal))
    }

    var body: some View {
        VStack {
            Text(store.state.animal.currentAnimal).font(.system(.largeTitle)).padding()
            Button(Tap me, action: { self.loadAnimal() })
        }
    }
}

That’s it. If you try build and run the app, it will launch, and after some seconds, a new Animal will appear on the screen, and the same will happen when you tap the button.

It’s all good, but the User Experience not so much. It takes time to display an animal after you press the button, and you have no feedback on the screen. So let’s update our UI to display a “Loading” message, while our animal is fetched.

The easiest way to do this, would be displaying a “Loading” message in the same label, while the animal name is being loaded, so let’s modify our animalReducer.

import Foundation

func animalReducer(state: inout AnimalState, action: AnimalAction) -> Void {
    switch(action) {
        case .fetchAnimal:
            state.currentAnimal = "Loading..."
        case .setCurrentAnimal(let animal):
            state.currentAnimal = animal            
    }
}

As you can see, I’m just modifying our state when that actions arrives. Now try and run the app again, and you should get something like this:

The Power of Middlewares

Having Middlewares in place, allows us to do all sorts of things in the application, as we can easily intercept any action that passes through our Store.

For example, creating a Middleware, that Logs every action to the console is as simple as writing a function.

import Foundation
import Combine

func logMiddleware() -> Middleware<AppState, AppAction> {

    return { state, action in
        print("Triggered action: \(action)")
        return Empty().eraseToAnyPublisher()
    }


}

And then simply add it to your store Initialization

let store = AppStore(initialState: .init(
                            animal: AnimalState()
                        ),
                      reducer: appReducer,
                      middlewares: [
                        animalMiddleware(service: AnimalService()),
                        logMiddleware()
                      ])

Remember that every Middleware receives a full copy of you app’s state, and can trigger whatever action you need, so possibilities are limitless.

Note on Side Effects, Middlewares and Thread Safety

This approach allows us to add as many Middlewares we need, but you need to consider that everything we are doing is asynchronous. All middlewares run at the same time, with an identical copy of the current state.

Given that we will have multiple threads running in parallel, you should pay special attention to what you do at middleware level. In general, you should avoid intercepting the same Action in different Middlewares, as there is no thread synchronization between them, and you will certainly face some race conditions.

A way to fix this issue will be running our Middlewares in sequence, with each one waiting for the previous one to complete, but that also impacts performance.

Perhaps there’s good material there for another post.

Conclusion

We did a lot of things in this second part of the tutorial and now you have a really robust Architecture, that would allow you to write testable apps in no time.

Check out the resulting app for this post is the repo., where we will be implementing proper error handling, so our users can be informed if something has gone wrong.

I hope you enjoyed this tutorial. If you have any questions or comments, feel free to ping me on Twitter.


Posts in this series

  1. The Basics
  2. Side Effects (This Post)
  3. Error Handling
  4. Real World App

Sources & Refs: